4.3 关系的不对称与变化

关系有两副面孔:一副是矩阵里静态的不对称,一副是沿路径动态的变化。本节用自实现 MINSTD 采样 20000 条路径,输出 4 个好感度变量的「初始值 → 终局值」分布表,断言整数不变量残差为 0,并统计出 15 对关系里 13 对不对称、2 对差距达到 20。

关系在散文里被写成「单向的」:一个人默默记着,另一个人并不知道。站内 独立游戏的叙事设计与分支对话 讲的就是这种落差怎么让角色立起来,那是经验与清单,不能跑。本节把落差变成两个可算的量:矩阵里 A 对 B 与 B 对 A 的静态不对称,以及好感度沿路径推进出的动态变化。

前者在 4.2 已经露过头(13/15 对不对称),后者要跑起来才看得见——因为变化不是矩阵的属性,是路径的属性。

4.3 关系的不对称与变化

两种「不一样」

静态不对称动态变化
载体关系矩阵 R[a][b] 与 R[b][a]state.vars 里的好感度变量
依赖路径不依赖依赖:同一对关系在不同路径上落到不同值
度量差的绝对值 `R[a][b] − R[b][a]
本节口径差距 ≥ 20 视为显著20000 条采样路径上的分布

两者是独立的:一对关系可以静态对称、动态剧烈变化,也可以静态不对称、动态纹丝不动。

变化的载体:只有 4 个变量

state.vars 只有 4 个——yan / hui / shen / mu。characters[] 里 6 个角色都声明了 affinity{init,range,thresholds,gates},但 lin 与 hai 没有对应的变量:全篇没有任何选项 add 它们。这两个角色的关系在整个流程里是常量,它们的不对称只能从 4.2 的矩阵里读,读不到任何路径变化。这是 4.1 那类「字段存在但不被消费」的问题在关系层的同一形态。

S 层:采样 + 分布表

脚本用自实现 MINSTD(state = state × 48271 % 2147483647,seed = 20261016)采样 20000 条路径,每条推进到终局,然后输出每个变量的「初始值 → 终局值」分布表(按 10 分箱),并断言三条整数不变量:终局值 ∈ [0,100]、分箱计数守恒、无卡死路径。同时统计矩阵的静态不对称度。

#!/usr/bin/env python3
# file: sim/na_04_3_relationship_change.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_04_3_relationship_change.py
"""4.3 关系的不对称与变化。

不对称是静态的:关系矩阵里 R[a][b] 与 R[b][a] 可以不同(4.2 给出矩阵)。
变化是动态的:只有进入 state.vars 的 4 个好感度变量(yan / hui / shen / mu)
沿路径改变;lin / hai 声明了 affinity 但没有变量,其关系在整个流程里是常量。

本脚本用自实现 MINSTD(seed = 20261016)采样 20000 条路径,输出每个变量的
「初始值 → 终局值」分布表(按 10 分箱),断言整数不变量(终局值 ∈ [0,100]、
分箱计数守恒、无卡死路径),并统计关系矩阵的不对称度。产出 out/na_04_3_*。
"""
from __future__ import annotations

import csv
import json
import os
import sys
import xml.etree.ElementTree as ET

NS = "http://www.w3.org/2000/svg"
ET.register_namespace("", NS)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LO, HI, N, SEED, MAXS, NBIN, ASYM = 0, 100, 20000, 20261016, 200, 10, 20
BAR, MARK = "#38bdf8", "#dc2626"


def load(rel):
    with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
        return json.load(fh)


def clamp(v):
    return LO if v < LO else HI if v > HI else v


def ok(c, s):
    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 ok(c[1], s) and ok(c[2], s)
    if k == "or":
        return ok(c[1], s) or ok(c[2], s)
    if k == "not":
        return not ok(c[1], s)
    raise ValueError(k)


def apply(eff, s):
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k in sorted(eff.get("set") or {}):
        flags[k] = eff["set"][k]
    for k in sorted(eff.get("add") or {}):
        aff[k] = clamp(aff.get(k, 0) + eff["add"][k])
    for k in sorted(eff.get("grant") or {}):
        items[k] = eff["grant"][k]
    for k in sorted(eff.get("revoke") or {}):
        items[k] = 0
    return {"aff": aff, "flags": flags, "items": items}


def bl(i):
    return "90-100" if i == NBIN - 1 else f"{i * 10}-{i * 10 + 9}"


def el(t, **a):
    return ET.Element(f"{{{NS}}}{t}", {k: str(v) for k, v in a.items()})


def tx(p, x, y, s, n=12, an="start", f="#334155"):
    t = el("text", x=x, y=y, **{"font-size": n, "font-family": "ui-sans-serif",
                                "text-anchor": an, "fill": f})
    t.text = s
    p.append(t)


def hist_svg(vars_, hist, init, path):
    pw, ph, gap = 352, 186, 24
    W, H = 28 + pw * 2 + gap + 12, 92 + (ph + 64) * 2
    vmax = max(max(h) for h in hist.values()) or 1
    s = el("svg", width=W, height=H, viewBox=f"0 0 {W} {H}")
    s.append(el("rect", x=0, y=0, width=W, height=H, fill="#ffffff"))
    tx(s, 24, 28, "《雾港夜航》好感度变量的「初始值 → 终局值」分布", 14, f="#0f172a")
    tx(s, 24, 48, f"{N} 条采样路径(MINSTD seed={SEED});横轴 = 终局值 10 分箱,纵轴 = 路径数", 11, f="#64748b")
    tx(s, 24, 66, "红色虚线 = 该变量的初始值所在箱", 11, f="#64748b")
    for k, vid in enumerate(vars_):
        ox, oy = 28 + (k % 2) * (pw + gap), 92 + (k // 2) * (ph + 64)
        tx(s, ox, oy, f"{vid}(init = {init[vid]})", 12, f="#0f172a")
        base, bw = oy + 22 + ph, pw // NBIN
        s.append(el("line", x1=ox, y1=base, x2=ox + pw, y2=base, stroke="#94a3b8", **{"stroke-width": 1}))
        for b in range(NBIN):
            h = (hist[vid][b] * ph) // vmax
            x = ox + b * bw
            if h:
                s.append(el("rect", x=x + 2, y=base - h, width=bw - 4, height=h, rx=3, fill=BAR))
                tx(s, x + bw // 2, base - h - 4, str(hist[vid][b]), 9, "middle", "#0c4a6e")
            tx(s, x + bw // 2, base + 16, bl(b), 9, "middle", "#94a3b8")
        ix = ox + min(init[vid] // 10, NBIN - 1) * bw + bw // 2
        s.append(el("line", x1=ix, y1=oy + 22, x2=ix, y2=base, stroke=MARK,
                    **{"stroke-width": 2, "stroke-dasharray": "4 3"}))
    t = ET.ElementTree(s)
    ET.indent(t, space="  ")
    t.write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main():
    spec, rel = load("config/story_spec.json"), load("config/na_04_2_relations.json")
    by_id = {n["id"]: n for n in spec["dialogue"]}
    vars_ = sorted(spec["state"]["vars"])
    chars = {c["id"]: c for c in spec["characters"]}
    init = {v: chars[v]["affinity"]["init"] for v in vars_}
    untracked = [c["id"] for c in spec["characters"] if c["id"] not in vars_]
    rng = SEED

    def rnd(m):
        nonlocal rng
        rng = (rng * 48271) % 2147483647
        return rng % m

    hist = {v: [0] * NBIN for v in vars_}
    ends, dead, oor = {}, 0, 0
    at_lo, at_hi, dsum = {v: 0 for v in vars_}, {v: 0 for v in vars_}, {v: 0 for v in vars_}
    dmin, dmax, seen = {v: 10 ** 9 for v in vars_}, {v: -(10 ** 9) for v in vars_}, {v: set() for v in vars_}
    for _ in range(N):
        node = spec["meta"]["start"]
        st = {"aff": dict(init), "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
              "items": {i: 0 for i in sorted(spec["state"]["items"])}}
        for _step in range(MAXS):
            cur = by_id[node]
            if cur.get("ending"):
                ends[cur["ending"]] = ends.get(cur["ending"], 0) + 1
                break
            opts = [c for c in (cur.get("choices") or []) if ok(c.get("when"), st)]
            if not opts:
                dead += 1
                break
            ch = opts[rnd(len(opts))]
            st = apply(ch.get("effects") or {}, st)
            node = ch["goto"]
        else:
            dead += 1
        for v in vars_:
            fv = st["aff"][v]
            if not (LO <= fv <= HI):
                oor += 1
            hist[v][min(fv // 10, NBIN - 1)] += 1
            seen[v].add(fv)
            d = fv - init[v]
            dsum[v] += d
            dmin[v], dmax[v] = min(dmin[v], d), max(dmax[v], d)
            at_lo[v] += fv == LO
            at_hi[v] += fv == HI

    ids, mat = rel["ids"], rel["matrix"]
    asym = sorted(({"a": a, "b": b, "ab": mat[i][ids.index(b)], "ba": mat[ids.index(b)][i],
                    "gap": abs(mat[i][ids.index(b)] - mat[ids.index(b)][i])}
                   for i, a in enumerate(ids) for b in ids[i + 1:]),
                  key=lambda r: (-r["gap"], r["a"], r["b"]))
    over = [r for r in asym if r["gap"] >= ASYM]

    assert oor == 0 and dead == 0, f"越界 {oor} / 卡死 {dead}"
    assert sum(sum(h) for h in hist.values()) == N * len(vars_), "分箱计数不守恒"

    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    rep = {"sample_n": N, "seed": SEED, "vars": vars_, "init": init,
           "untracked_characters": untracked, "hist": hist,
           "bin_labels": [bl(i) for i in range(NBIN)],
           "final_min": {v: min(seen[v]) for v in vars_}, "final_max": {v: max(seen[v]) for v in vars_},
           "distinct_finals": {v: len(seen[v]) for v in vars_}, "delta_min": dmin, "delta_max": dmax,
           "delta_mean_milli": {v: (dsum[v] * 1000) // N for v in vars_},
           "hit_lo": at_lo, "hit_hi": at_hi, "out_of_range": oor, "dead_ends": dead,
           "ending_distribution": ends, "asymmetry_pairs": sum(1 for r in asym if r["gap"] > 0),
           "asymmetry_total_pairs": len(asym), "asymmetry_over_threshold": over,
           "asymmetry_all": asym}
    with open(os.path.join(out, "na_04_3_relationship_change.json"), "w", encoding="utf-8") as fh:
        json.dump(rep, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    with open(os.path.join(out, "na_04_3_relationship_change.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["var", "init"] + [bl(i) for i in range(NBIN)])
        for v in vars_:
            w.writerow([v, init[v]] + hist[v])
    size = hist_svg(vars_, hist, init, os.path.join(out, "na_04_3_relationship_change.svg"))
    rects = sum(1 for e in ET.parse(os.path.join(out, "na_04_3_relationship_change.svg")).getroot().iter()
                if isinstance(e.tag, str) and e.tag.endswith("rect"))
    assert rects >= len(vars_) + 1

    print(f"采样 {N} 条路径,seed={SEED};结局分布:{ends}")
    print(f"跟踪的关系变量 {len(vars_)} 个:{vars_};声明了 affinity 但无变量的角色:{untracked}")
    print("「初始值 → 终局值」分布表(每格 = 路径数)")
    print(f"  {'变量':<5}{'init':>5}  " + "".join(f"{bl(i):>9}" for i in range(NBIN)))
    for v in vars_:
        print(f"  {v:<5}{init[v]:>5}  " + "".join(f"{hist[v][i]:>9}" for i in range(NBIN)))
    print(f"  {'变量':<5}{'init':>5}{'终局min':>8}{'终局max':>8}{'不同终局':>9}"
          f"{'Δmin':>6}{'Δmax':>6}{'Δ均值×1000':>12}{'触底':>6}{'触顶':>6}")
    for v in vars_:
        print(f"  {v:<5}{init[v]:>5}{min(seen[v]):>8}{max(seen[v]):>8}{len(seen[v]):>9}"
              f"{dmin[v]:>6}{dmax[v]:>6}{rep['delta_mean_milli'][v]:>12}{at_lo[v]:>6}{at_hi[v]:>6}")
    print(f"静态不对称 {rep['asymmetry_pairs']}/{len(asym)} 对;差距 ≥ {ASYM} 的结对 {len(over)} 个:"
          + " · ".join(f"{r['a']}↔{r['b']} {r['ab']}/{r['ba']}(差 {r['gap']})" for r in over))
    print(f"整数不变量:终局值越界 {oor} · 分箱守恒 {sum(sum(h) for h in hist.values())}"
          f" = {N}×{len(vars_)} · 卡死路径 {dead}")
    print(f"wrote out/na_04_3_relationship_change.json / .csv / .svg({size} bytes,rect {rects})")
    print("OK na_04_3_relationship_change:分布表与不对称统计完成,整数不变量残差 0")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑输出

采样 20000 条路径,seed=20261016;结局分布:{'离港': 10074, '沉没': 5507, '缄默': 3223, '灯守': 1018, '真相': 178}
跟踪的关系变量 4 个:['hui', 'mu', 'shen', 'yan'];声明了 affinity 但无变量的角色:['lin', 'hai']
「初始值 → 终局值」分布表(每格 = 路径数)
  变量    init        0-9    10-19    20-29    30-39    40-49    50-59    60-69    70-79    80-89   90-100
  hui     50          0      253     1696     1504     5860     4824     4228      944      587      104
  mu      50          0        0      104     1177     5303     8034     3421      938      595      428
  shen    50          0        0        0        0        0     7267     4906     4400     2213     1214
  yan     20        246     1974     4324     5058     4160     2594     1107      375      129       33
  变量    init   终局min   终局max     不同终局  Δmin  Δmax    Δ均值×1000    触底    触顶
  hui     50      10      97       35   -40    47         152     0     0
  mu      50      25     100       16   -25    50        2539     0   125
  shen    50      50     100       11     0    50       14858     0   282
  yan     20       0     100       21   -20    80       15599    45     2
静态不对称 13/15 对;差距 ≥ 20 的结对 2 个:yan↔hui 35/60(差 25) · yan↔hai 55/75(差 20)
整数不变量:终局值越界 0 · 分箱守恒 80000 = 20000×4 · 卡死路径 0
wrote out/na_04_3_relationship_change.json / .csv / .svg(12328 bytes,rect 32)
OK na_04_3_relationship_change:分布表与不对称统计完成,整数不变量残差 0

实测:四个变量的「初始值 → 终局值」

四个变量里三个 init 都是 50,只有砚是 20。分布表里有三件事值得读:

砚的分布最宽(21 个不同终局值,从 0 到 100)。它的最大涨幅 +80 正好是 init 20 到上界 100 的距离,也就是被 clamp 截断——有 2 条路径触顶。最大跌幅 −20 则触底:45 条路径落到 0。砚同时是唯一一个既触底又触顶的变量。

沐与沈的分布偏上。沈的 Δmin 是 0:20000 条采样路径里没有一条低于初始值,触底 0 次而触顶 282 次;沐的 Δmin 也只有 −25。这不是「角色写得单调」,而是负向选项的幅度压不过正向之和——沈三处负向各 −5(合计 −15),沐三处为 −5 / −10 / −10(合计 −25),而正向选项单笔就能给到 +15 甚至 +20(沈的正向合计 +135,沐 +75)。规格里的增减幅度直接决定了分布形状。

徽的最大跌幅 −40 是可解释的:她四处负向选项(c_tavern_1c −5、c_tavern_2b −10、c_bw_1c −15、c_bw_2b −10)之和正好是 −40,也就是说至少有一条路径把它们全踩了。分布表能把「一个极端值」还原成「哪几个选项的组合」。

静态不对称这边:15 对里 13 对不对称,差距 ≥ 20 的有 2 对——砚↔徽 35/60(差 25)、砚↔孩 55/75(差 20)。前一对同时是 4.2 里偏离派系先验的格子之一:同一个格子在两个口径下都显眼,说明它不是采样噪声,是设计意图的落点。

与前面各节的对账

采样的推进语义与第一章的 na_00_spec_check.py 同算法同种子,所以结局分布可以逐项对账:离港 10074 · 沉没 5507 · 缄默 3223 · 灯守 1018 · 真相 178,与第一章报告的采样口径一致。本节只是在这些路径上多读了一个字段——好感度的初值与终值。

阅读导航:上一节:4.2 关系矩阵 · 下一节:5.1 弧光的阶段与转折 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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