关系在散文里被写成「单向的」:一个人默默记着,另一个人并不知道。站内 独立游戏的叙事设计与分支对话 讲的就是这种落差怎么让角色立起来,那是经验与清单,不能跑。本节把落差变成两个可算的量:矩阵里 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 弧光的阶段与转折 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。