4.2 关系矩阵

故事里谁对谁是什么态度,规格里只有派系立场这一层。本节把 6 个角色之间的关系写成 6×6 整数矩阵,校验形状、值域与对角线,再用派系立场推出先验做一致性交叉检查:30 个非对角格里 2 个偏离先验,15 对里有 13 对不对称,而派系立场本身却完全对称。

独立游戏的叙事设计与分支对话 里,人物关系是靠事件与台词让玩家自己感觉出来的——谁欠谁、谁在怕谁、谁嘴上不说。那篇给的是经验,不能跑。本节要做的是把关系写成矩阵:6 个角色两两之间的态度落成整数,于是「关系」变成一个可以校验形状、检查值域、还能跟派系立场对账的对象。

先说规格现状:story_spec.json 里没有角色之间的关系数据。它有的是 world.factions[].stance——4 个派系彼此之间的 −1 / 0 / +1 立场矩阵。角色关系必须由本章补一层,就像 4.1 补了锚点表一样。

4.2 关系矩阵

表示:行 = 观察者,列 = 对象

矩阵是 6×6,与 characters 的顺序一致;R[a][b] 表示 a 对 b 的态度,整数 0–100,对角线固定 0(自己对自己没有关系值)。取值口径与好感度同源:整数、无浮点、有界。

{
  "note": "角色关系矩阵:行 = 观察者,列 = 对象,整数 0–100,对角线固定 0。story_spec 只给派系立场,角色级关系由本表给出。",
  "ids": ["yan", "hui", "shen", "mu", "lin", "hai"],
  "matrix": [
    [0, 35, 40, 45, 70, 55],
    [60, 0, 35, 45, 60, 70],
    [55, 45, 0, 50, 45, 55],
    [50, 55, 50, 0, 55, 45],
    [80, 55, 40, 50, 0, 65],
    [75, 65, 40, 45, 60, 0]
  ]
}

与 world.factions[].stance 的关系

派系立场给的是一个先验:同派系之间中性,互相敌视的派系之间低,结盟的派系之间高。把它线性映射成角色关系的先验值:

prior(a, b) = 50 + 10 × stance(faction(a), faction(b))

于是 stance 为 +1 的结对先验 60、0 的结对先验 50、−1 的结对先验 40。角色关系如果大幅偏离这个先验,说明剧情在派系立场之外额外给了这对关系——这是设计空间,不是错误。所以校验的判据是「偏离超过容差就列出来」,而不是「偏离就判错」。

S 层:四类校验

脚本做四件事:形状(6×6,ids 与 characters 顺序一致);值域(全部 int 且 ∈ [0,100],对角线恒 0);派系立场矩阵自身(4×4、对角 0、取值 ∈ {−1,0,1}、是否对称);与派系先验的一致性(逐格算 prior 与偏差,超过容差 20 的列出)。最后统计矩阵的静态不对称度。

#!/usr/bin/env python3
# file: sim/na_04_2_relationship_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_04_2_relationship_matrix.py
"""4.2 关系矩阵:6×6 角色关系矩阵的形状 / 值域 / 对角线校验,
以及与 world.factions[].stance 的一致性交叉检查。

story_spec 里没有角色之间的关系数据(只有派系立场 stance 4×4),角色关系矩阵
由 config/na_04_2_relations.json 给出:行 = 观察者,列 = 对象,整数 0–100,
对角线固定 0。stance 映射成先验 prior = 50 + 10 × stance,角色关系偏离 prior
超过容差 20 的格子逐条列出——它们不是错误,而是剧情在派系先验之外额外给出的
关系,正是 4.3 要接着讲的不对称。产出 out/na_04_2_relationship_matrix.*。
"""
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, TOL, NEUTRAL, STEP = 0, 100, 20, 50, 10
RAMP = [(0, 0, "#f1f5f9", "#94a3b8"), (1, 19, "#e0f2fe", "#075985"),
        (20, 39, "#bae6fd", "#075985"), (40, 59, "#7dd3fc", "#0c4a6e"),
        (60, 79, "#38bdf8", "#ffffff"), (80, 100, "#0284c7", "#ffffff")]


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


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 heat_svg(ids, mat, dev, path):
    cw, chh, lw, top = 84, 44, 96, 86
    W, H = lw + cw * len(ids) + 26, top + chh * len(ids) + 66
    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, 18, 26, "《雾港夜航》角色关系矩阵(行 = 观察者,列 = 对象)", 14, f="#0f172a")
    tx(s, 18, 46, "整数 0–100,对角线固定 0;红框 = 与派系先验偏离 > 20 的格子", 11, f="#64748b")
    tx(s, 18, 66, f"先验 prior = {NEUTRAL} + {STEP} × stance(faction(行), faction(列))", 11, f="#64748b")
    for j, cid in enumerate(ids):
        tx(s, lw + cw * j + cw // 2, top - 12, cid, 12, "middle", "#475569")
    for i, cid in enumerate(ids):
        y = top + chh * i
        tx(s, lw - 12, y + 28, cid, 12, "end", "#0f172a")
        for j in range(len(ids)):
            v = mat[i][j]
            fill, fg = next((f, g) for lo, hi, f, g in RAMP if lo <= v <= hi)
            s.append(el("rect", x=lw + cw * j + 2, y=y + 3, width=cw - 4, height=chh - 6, rx=5,
                        fill=fill, stroke="#dc2626" if (i, j) in dev else "#cbd5e1",
                        **{"stroke-width": 2 if (i, j) in dev else 1}))
            tx(s, lw + cw * j + cw // 2, y + 28, str(v), 13, "middle", fg)
    tx(s, 18, H - 34, f"偏离先验的格子 {len(dev)} / {len(ids) * (len(ids) - 1)} 个非对角格", 12, f="#0f172a")
    tx(s, 18, H - 14, "矩阵来源:config/na_04_2_relations.json", 11, f="#64748b")
    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")
    cids = [c["id"] for c in spec["characters"]]
    faction_of = {c["id"]: c["faction"] for c in spec["characters"]}
    fids = [f["id"] for f in spec["world"]["factions"]]
    stance = {f["id"]: f["stance"] for f in spec["world"]["factions"]}
    ids, mat, problems = rel["ids"], rel["matrix"], []

    if ids != cids:
        problems.append(f"关系矩阵 ids 与 characters 不一致:{ids} vs {cids}")
    for i in range(len(ids)):
        if len(mat[i]) != len(ids):
            problems.append(f"第 {i} 行列数 {len(mat[i])} != {len(ids)}")
    off = 0
    for i in range(len(ids)):
        for j in range(len(ids)):
            v = mat[i][j]
            if not isinstance(v, int) or isinstance(v, bool):
                problems.append(f"R[{ids[i]}][{ids[j]}] 不是整数:{v!r}")
            elif i == j:
                if v != 0:
                    problems.append(f"对角线 R[{ids[i]}][{ids[i]}] 应为 0,实为 {v}")
            else:
                off += 1
                if not (LO <= v <= HI):
                    problems.append(f"R[{ids[i]}][{ids[j]}] 越界:{v}")
    cells = 0
    for a in fids:
        if sorted(stance[a]) != sorted(fids):
            problems.append(f"stance[{a}] 的列集合与 factions 不一致")
            continue
        for b in fids:
            v = stance[a][b]
            cells += 1
            if not isinstance(v, int) or isinstance(v, bool) or not (-1 <= v <= 1):
                problems.append(f"stance[{a}][{b}] 越界:{v!r}")
            elif a == b and v != 0:
                problems.append(f"stance[{a}][{a}] 应为 0,实为 {v}")
    s_asym = [(a, b) for i, a in enumerate(fids) for b in fids[i + 1:] if stance[a][b] != stance[b][a]]

    pairs, dev = [], set()
    for i, a in enumerate(ids):
        for j, b in enumerate(ids):
            if i == j:
                continue
            prior = NEUTRAL + STEP * stance[faction_of[a]][faction_of[b]]
            ok = abs(mat[i][j] - prior) <= TOL
            if not ok:
                dev.add((i, j))
            pairs.append({"from": a, "to": b, "fa": faction_of[a], "fb": faction_of[b],
                          "value": mat[i][j], "prior": prior, "dev": mat[i][j] - prior, "ok": ok})
    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"]))

    assert off == len(ids) * (len(ids) - 1) and all(LO <= r["value"] <= HI for r in pairs)
    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    rep = {"ids": ids, "shape": [len(ids), len(ids)], "off_diagonal_cells": off, "matrix": mat,
           "tolerance": TOL, "factions": fids, "stance_cells": cells,
           "stance_asymmetric_pairs": [f"{a}->{b}" for a, b in s_asym],
           "consistent_pairs": sum(1 for r in pairs if r["ok"]),
           "deviating_pairs": [{k: r[k] for k in ("from", "to", "value", "prior", "dev")}
                               for r in pairs if not r["ok"]],
           "asymmetry_pairs": sum(1 for r in asym if r["gap"] > 0),
           "asymmetry_total_pairs": len(asym), "problems": problems}
    with open(os.path.join(out, "na_04_2_relationship_matrix.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_2_relationship_matrix.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["from", "to"] + ids)
        for i, a in enumerate(ids):
            w.writerow([a, "->"] + mat[i])
    size = heat_svg(ids, mat, dev, os.path.join(out, "na_04_2_relationship_matrix.svg"))
    rects = sum(1 for e in ET.parse(os.path.join(out, "na_04_2_relationship_matrix.svg")).getroot().iter()
                if isinstance(e.tag, str) and e.tag.endswith("rect"))
    assert rects == len(ids) * len(ids) + 1

    print(f"角色关系矩阵 {len(ids)}×{len(ids)}:非对角格 {off},值域 [{LO},{HI}],对角线 0")
    print("        " + "".join(f"{c:>6}" for c in ids))
    for i, a in enumerate(ids):
        print(f"  {a:<5} " + "".join(f"{mat[i][j]:>6}" for j in range(len(ids))))
    print(f"派系立场矩阵 {len(fids)}×{len(fids)}:格 {cells},非对称对 {len(s_asym)}"
          f"(本案例 stance 对称,而角色关系有 {rep['asymmetry_pairs']}/{len(asym)} 对不对称)")
    print(f"与派系先验一致 {rep['consistent_pairs']}/{len(pairs)}(容差 {TOL})")
    for r in pairs:
        if not r["ok"]:
            print(f"  WARN 偏离 {r['from']}->{r['to']}({r['fa']}→{r['fb']}):值 {r['value']}"
                  f" vs 先验 {r['prior']},偏差 {r['dev']:+d}")
    print("不对称度 Top 5:" + " · ".join(
        f"{r['a']}↔{r['b']} {r['ab']}/{r['ba']}(差 {r['gap']})" for r in asym[:5]))
    for p in problems:
        print(f"FAIL {p}")
    if problems:
        return 1
    print(f"wrote out/na_04_2_relationship_matrix.json / .csv / .svg({size} bytes,rect {rects})")
    print("OK na_04_2_relationship_matrix:形状 / 值域 / 对角线 / 整数不变量全部通过")
    return 0


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

实跑输出

角色关系矩阵 6×6:非对角格 30,值域 [0,100],对角线 0
           yan   hui  shen    mu   lin   hai
  yan        0    35    40    45    70    55
  hui       60     0    35    45    60    70
  shen      55    45     0    50    45    55
  mu        50    55    50     0    55    45
  lin       80    55    40    50     0    65
  hai       75    65    40    45    60     0
派系立场矩阵 4×4:格 16,非对称对 0(本案例 stance 对称,而角色关系有 13/15 对不对称)
与派系先验一致 28/30(容差 20)
  WARN 偏离 yan->hui(watch→guild):值 35 vs 先验 60,偏差 -25
  WARN 偏离 lin->yan(watch→watch):值 80 vs 先验 50,偏差 +30
不对称度 Top 5:yan↔hui 35/60(差 25) · yan↔hai 55/75(差 20) · shen↔hai 55/40(差 15) · yan↔shen 40/55(差 15) · hui↔mu 45/55(差 10)
wrote out/na_04_2_relationship_matrix.json / .csv / .svg(10247 bytes,rect 37)
OK na_04_2_relationship_matrix:形状 / 值域 / 对角线 / 整数不变量全部通过

实测:2 个格子偏离派系先验

30 个非对角格里有 2 个偏离先验超过 20:

结对派系值先验偏差剧情上的原因
砚 → 徽watch → guild(+1)3560−25三夜前徽拒了砚的渡船请求
林 → 砚watch → watch(0)8050+30同属守望者工会,林是默许者也是交接者

这两个格子恰好是本章最有信息量的地方:派系立场只能给出先验,同派系内部的关系可以远高于先验,跨派系的合作关系也可以远低于先验。如果只看 stance,砚对徽应当是 60(守望者与船帮结盟),但剧情里这条关系是负向的——矩阵把「派系层面的结盟」与「个人层面的怨」分开了。

反过来,28/30 个格子落在容差内,说明派系立场对角色关系确实是一个有效但粗糙的先验:它解释了大部分格子,剩下两个正好是需要剧情补足的位置。

矩阵的两条不变量与一个观察

两条不变量是硬约束,脚本每次跑都断言:非对角格数恒等于 6×5 = 30,所有值恒在 [0,100] 内。对角线恒 0 则是矩阵自身的定义。

一个观察:派系立场矩阵在本案例里是对称的——4 个派系的 16 个格子里,stance[a][b] == stance[b][a] 对全部 6 个无序对成立(非对称对 0 个)。但角色关系矩阵有 13/15 对不对称:砚对徽 35、徽对砚 60;砚对孩 55、孩对砚 75。派系之间是「立场对立场」,天然可以对称;角色之间是「人对人」,同一个态度两侧不对称才是常态。这正好是下一节要展开的东西。

阅读导航:上一节:4.1 角色的字段:目标、恐惧、秘密 · 下一节:4.3 关系的不对称与变化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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