3.1 派系、地点与规则

世界观在规格里是三组可校验的字段:4×4 的派系 stance 矩阵表达谁与谁对立,地点 unlock_when 表达空间解锁,rules 表达可检查的约束。本节用一个 S 层脚本把三者都读成可判定的量,逐对检验 stance 矩阵的反对称性,校验 14 个地点的解锁条件与提供者,并把 4 条自然语言规则整理成待绑定的清单。

3.1 派系、地点与规则

游戏叙事设计概览 用 93 行散文谈了「世界观设定为什么不能只写在文档里」,那是散文式、不可跑的概论;本节把世界观里最像「设定」的三类东西——派系、地点、规则——写成可校验的字段,并让一个 S 层脚本去判定它们自洽不自洽。story_spec.json 的 world 段就是这三类加一条 lore,全部是结构,没有一句描述性文字参与判定。

世界观在规格里的四个字段

字段形态可检查程度谁消费
factions[].stance4×4 整数矩阵(−1 / 0 / +1)高:可判反对称、可判失衡环本节、3.2
locations[].unlock_when条件文法的一个原子或 null高:可判命名空间与提供者第 7 章场景可达性
lore[].reveal_flag一个 flag 名中:可判 flag 存在,可统计揭示位置3.3
rules[]一条自然语言低:须先写读法表才能判定3.2 规则反证

四类里只有前两类天生可判定。lore 要靠一条 reveal_flag 把自己挂到状态上才可算;rules 是纯自然语言,必须先被读成约束——这一步是建模,不是解析,所以读法表必须显式写出来(3.2 会给出)。

stance 矩阵:把「谁与谁对立」写成 4×4

stance 是一个 4×4 的整数矩阵,第 A 行第 B 列的值表示派系 A 对派系 B 的态度,取值只有三种:-1 对立、0 中立、+1 结盟。对角线恒为 0(一个派系对自己没有态度),这条本身就是一条整数不变量,残差必须为 0。

用整数而不是「关系强度 0.0–1.0」,是因为整数可以直接比较、可以直接相乘。乘起来这件事在 3.2 会派上大用场:带符号边的环,符号积为 -1 就是失衡。用浮点数就没法判「恰好为负」。

一次逐对检验:反对称还是对称

矩阵一写出来,「谁与谁对立」就有了两种可能的读法,必须选一种并写死检验口径:

  • 反对称:s(A,B) + s(B,A) == 0——A 对 B 是 +1 时,B 对 A 必须是 -1,态度是单向的。
  • 对称:s(A,B) == s(B,A)——两边取值相同,态度是相互的。

两个口径都跑一遍,结论一目了然。脚本对 6 个无序对逐一检验,并顺手校验 unlock_when 的命名空间与提供者、整理 rules 清单。

#!/usr/bin/env python3
# file: sim/na_03_faction_stance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_03_faction_stance.py
"""3.1 派系、地点与规则(S 层):stance 矩阵的反对称性检验、地点解锁校验、规则清单。

`world` 段的三类字段,可检查的程度各不相同:

  - `factions[].stance` 是 4×4 的**整数矩阵**,取值 -1 / 0 / +1,表达「谁与谁对立」;
  - `locations[].unlock_when` 是条件文法的一个**原子**(flag / item)或 null;
  - `rules[]` 是 4 条**自然语言**约束,可检查性最低,必须靠一张显式的读法表。

本脚本把前两类读成可判定的量,并把第三类整理成待绑定的清单:

  ① 反对称性:对每个无序对 {A,B},检验 s(A,B) + s(B,A) == 0;
     同时给出对称性 s(A,B) == s(B,A) 作对照——矩阵究竟是「相互」还是「单向」。
  ② 地点解锁:每条 unlock_when 的原子是否落在 state 命名空间内,是否有提供者。
  ③ 规则清单:4 条规则的 id 唯一、text 非空,并统计主题词能否绑定到世界实体。

整数不变量(残差恒为 0):stance ∈ {-1,0,+1}、对角线恒为 0、unlock_when 第三分量恒为 1。
"""
from __future__ import annotations

import json
import os
import sys

STANCE_LO, STANCE_HI = -1, 1


def load_spec() -> dict:
    here = os.path.dirname(os.path.abspath(__file__))
    with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
        return json.load(fh)


def providers(flag: str | None, item: str | None, spec: dict) -> list[str]:
    """谁能让这个 flag / item 变真:扫全部选项效果,返回提供它的对话节点 id(去重保序)。"""
    out: list[str] = []
    for node in spec["dialogue"]:
        for c in (node.get("choices") or []):
            e = c.get("effects") or {}
            hit = flag is not None and flag in (e.get("set") or {})
            hit = hit or (item is not None and item in (e.get("grant") or {}))
            if hit and node["id"] not in out:
                out.append(node["id"])
    return out


def parse_atom(cond) -> tuple[str, str, int] | None:
    """unlock_when 的原子形态:["flag", id, 1] 或 ["item", id, 1];null 返回 None。"""
    if cond is None:
        return None
    return cond[0], cond[1], cond[2]


def main() -> int:
    spec = load_spec()
    world = spec["world"]
    factions = world["factions"]
    ids = [f["id"] for f in factions]
    flags = set(spec["state"]["flags"])
    items = set(spec["state"]["items"])

    problems: list[str] = []
    warns: list[str] = []

    # ---------------- ① stance 矩阵 ----------------
    m = {f["id"]: {k: v for k, v in f["stance"].items()} for f in factions}
    print("stance 矩阵(行=主动方,列=被动方;取值 -1 对立 / 0 中立 / +1 结盟)")
    print("        " + "".join(f"{i:>10}" for i in ids))
    for a in ids:
        print(f"{a:>8}" + "".join(f"{m[a][b]:>10}" for b in ids))

    # 整数不变量:取值域 + 对角线
    out_of_range = sum(1 for a in ids for b in ids if not (STANCE_LO <= m[a][b] <= STANCE_HI))
    diag_residual = sum(abs(m[a][a]) for a in ids)
    print(f"取值越界残差 = {out_of_range}  对角线残差 = {diag_residual}")
    if out_of_range:
        problems.append(f"stance 有 {out_of_range} 格越出 -1..+1")
    if diag_residual:
        problems.append(f"stance 对角线非 0,残差 {diag_residual}")

    # 无序对检验
    label = {(0, 0): "中立", (1, 1): "相互结盟", (-1, -1): "相互对立"}
    rows: list[list] = []
    anti = sym = 0
    for x in range(len(ids)):
        for y in range(x + 1, len(ids)):
            a, b = ids[x], ids[y]
            ab, ba = m[a][b], m[b][a]
            s = ab + ba
            is_anti = s == 0
            is_sym = ab == ba
            anti += is_anti
            sym += is_sym
            tag = label.get((ab, ba))
            if tag is None:
                tag = "单向立场" if 0 in (ab, ba) else "镜像对立"
            rows.append([f"{a}/{b}", ab, ba, s, str(is_anti), str(is_sym), tag])
    print("\n无序对检验(sum = s(A,B)+s(B,A);anti = sum==0;sym = s(A,B)==s(B,A))")
    print(f"{'pair':<20}{'s(A,B)':>8}{'s(B,A)':>8}{'sum':>6}{'anti':>7}{'sym':>6}  relation")
    for r in rows:
        print(f"{r[0]:<20}{r[1]:>8}{r[2]:>8}{r[3]:>6}{r[4]:>7}{r[5]:>6}  {r[6]}")
    print(f"反对称成立 {anti}/{len(rows)}  对称成立 {sym}/{len(rows)}")

    mutual_allies = [r[0] for r in rows if r[6] == "相互结盟"]
    mutual_foes = [r[0] for r in rows if r[6] == "相互对立"]
    one_sided = [r[0] for r in rows if r[6] == "单向立场"]
    print(f"相互结盟 {mutual_allies}")
    print(f"相互对立 {mutual_foes}")
    print(f"单向立场 {one_sided}")

    # ±1 子图的带符号三角形(与 3.2 的失衡检测同源,这里先给一个静态摘要)
    sign_edges = {(a, b): m[a][b] for a in ids for b in ids if a != b and m[a][b] != 0}
    print(f"±1 带符号边 {len(sign_edges)} 条(0 边 {len(ids) * (len(ids) - 1) - len(sign_edges)} 条不计入)")

    # ---------------- ② 地点解锁 ----------------
    print("\n地点解锁条件(unlock_when,只列带门槛的)")
    unlocked = 0
    open_ids: list[str] = []
    for loc in world["locations"]:
        atom = parse_atom(loc["unlock_when"])
        if atom is None:
            open_ids.append(loc["id"])
            continue
        unlocked += 1
        kind, name, truthy = atom
        ns_ok = (kind == "flag" and name in flags) or (kind == "item" and name in items)
        prov = providers(name if kind == "flag" else None, name if kind == "item" else None, spec)
        print(f"  {loc['id']:<18} {kind}:{name}  truthy={truthy}  命名空间={'ok' if ns_ok else 'BAD'}  提供者 {len(prov)} 处 {prov[:4]}")
        if not ns_ok:
            problems.append(f"地点 {loc['id']} 的 unlock_when 引用未知 {kind}:{name}")
        if truthy != 1:
            problems.append(f"地点 {loc['id']} 的 unlock_when 第三分量应为 1,实为 {truthy}")
        if not prov:
            warns.append(f"地点 {loc['id']} 的解锁条件 {name} 没有任何提供者(不可达)")
    print(f"  无门槛地点 {len(open_ids)} 个:{open_ids}")
    print(f"地点 {len(world['locations'])} 个:带门槛 {unlocked} 个,无门槛 {len(open_ids)} 个")

    # ---------------- ③ 规则清单 ----------------
    print("\n规则清单(自然语言,需读法表才能变可检查)")
    rule_ids: list[str] = []
    entity_ids = set(ids) | {c["id"] for c in spec["characters"]} | {loc["id"] for loc in world["locations"]}
    topic_bound = 0
    for r in world["rules"]:
        rule_ids.append(r["id"])
        topic = r["id"].removeprefix("rule_")
        bound = topic in entity_ids
        topic_bound += bound
        print(f"  {r['id']:<14} 主题 {topic:<10} 绑定实体={'是' if bound else '否'}  text 长度={len(r['text'])}")
    if len(rule_ids) != len(set(rule_ids)):
        problems.append("规则 id 有重复")
    if any(not r["text"].strip() for r in world["rules"]):
        problems.append("有规则的 text 为空")
    print(f"规则 {len(world['rules'])} 条:主题词直接绑定实体 {topic_bound} 条,需读法表 {len(world['rules']) - topic_bound} 条")

    # ---------------- 落盘 + 退出码 ----------------
    here = os.path.dirname(os.path.abspath(__file__))
    out = os.path.join(here, "../out/na03_stance.json")
    os.makedirs(os.path.dirname(out), exist_ok=True)
    with open(out, "w", encoding="utf-8") as fh:
        json.dump({
            "factions": ids,
            "matrix": m,
            "antisymmetric_pairs": anti,
            "symmetric_pairs": sym,
            "pairs": rows,
            "stance_residual": out_of_range + diag_residual,
            "locations_with_gate": unlocked,
            "rules": rule_ids,
        }, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    for w in warns:
        print(f"WARN {w}")
    for p in problems:
        print(f"FAIL {p}")
    if problems:
        return 1
    print(f"wrote out/na03_stance.json  整数不变量残差 {out_of_range + diag_residual}")
    print("OK na_03_faction_stance:stance 矩阵、地点解锁、规则清单全部解析")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/na_03_faction_stance.py:

stance 矩阵(行=主动方,列=被动方;取值 -1 对立 / 0 中立 / +1 结盟)
             guild    church  merchant     watch
   guild         0        -1         0         1
  church        -1         0         0        -1
merchant         0         0         0         1
   watch         1        -1         1         0
取值越界残差 = 0  对角线残差 = 0

无序对检验(sum = s(A,B)+s(B,A);anti = sum==0;sym = s(A,B)==s(B,A))
pair                  s(A,B)  s(B,A)   sum   anti   sym  relation
guild/church              -1      -1    -2  False  True  相互对立
guild/merchant             0       0     0   True  True  中立
guild/watch                1       1     2  False  True  相互结盟
church/merchant            0       0     0   True  True  中立
church/watch              -1      -1    -2  False  True  相互对立
merchant/watch             1       1     2  False  True  相互结盟
反对称成立 2/6  对称成立 6/6
相互结盟 ['guild/watch', 'merchant/watch']
相互对立 ['guild/church', 'church/watch']
单向立场 []
±1 带符号边 8 条(0 边 4 条不计入)

地点解锁条件(unlock_when,只列带门槛的)
  counting_house     flag:met_hui  truthy=1  命名空间=ok  提供者 1 处 ['d_tavern_1']
  watch_post         flag:knows_yan_missing  truthy=1  命名空间=ok  提供者 1 处 ['d_pier_1']
  lighthouse_base    flag:saw_lighthouse_light  truthy=1  命名空间=ok  提供者 5 处 ['d_hub_a2', 'd_lb_1', 'd_db_3', 'd_lt_1']
  tide_cave          flag:knows_tide_rule  truthy=1  命名空间=ok  提供者 3 处 ['d_pier_2', 'd_lb_1', 'd_ho_1']
  harbor_office      item:ledger_page  truthy=1  命名空间=ok  提供者 1 处 ['d_ch_3']
  drowned_bell       flag:knows_bell_legend  truthy=1  命名空间=ok  提供者 3 处 ['d_pier_2', 'd_chapel_1', 'd_mr_2']
  lighthouse_top     item:lantern  truthy=1  命名空间=ok  提供者 1 处 ['d_wp_3']
  无门槛地点 7 个:['pier', 'tavern', 'chapel', 'market_row', 'breakwater', 'fog_gate', 'dawn_quay']
地点 14 个:带门槛 7 个,无门槛 7 个

规则清单(自然语言,需读法表才能变可检查)
  rule_curfew    主题 curfew     绑定实体=否  text 长度=22
  rule_bell      主题 bell       绑定实体=否  text 长度=17
  rule_guild     主题 guild      绑定实体=是  text 长度=17
  rule_church    主题 church     绑定实体=是  text 长度=16
规则 4 条:主题词直接绑定实体 2 条,需读法表 2 条
wrote out/na03_stance.json  整数不变量残差 0
OK na_03_faction_stance:stance 矩阵、地点解锁、规则清单全部解析

读出来:立场是相互的,不是单向的

6 个无序对里,对称成立 6/6,反对称只成立 2/6。而且那 2 个「成立」的是 guild/merchant 与 church/merchant——两边都是 0,属于「两边都没态度」的平凡情形,0 + 0 == 0 顺带满足了反对称。真正有立场的 4 对(2 组相互结盟、2 组相互对立)全部是对称的:guild 与 watch 互认 +1,church 与 guild、church 与 watch 互记 -1。单向立场 0 条。

这带来一个可以直接说清的结论:在这份规格里,「谁与谁对立」被表达成双向关系,不是单相思。±1 带符号边共 8 条有向边(对应 4 条无向关系,另外 4 格是 0,不参与图)。8 条边里最值得记的是那个三派系环:church → guild → watch 的符号积为 +1,是平衡环——3.2 会把这条判据正式写成失衡检测。

地点解锁:unlock_when 是条件文法的一个原子

14 个地点里 7 个带门槛、7 个无门槛。带门槛的写法统一是一个原子:["flag", id, 1] 或 ["item", id, 1],而不是完整的嵌套条件。脚本对每个原子做三件事:查命名空间(flag 必须落在 state.flags,item 必须落在 state.items)、查第三分量是否为 1(条件文法的 truthy 约定)、数提供者(有几个对话节点能让它变真)。

提供者数量是本节的伏笔:met_hui、knows_yan_missing、ledger_page、lantern 各有唯一提供者,saw_lighthouse_light 有 5 处、knows_tide_rule 与 knows_bell_legend 各 3 处。提供者的多寡决定了 3.2 里那条解锁依赖边是硬边还是软边——唯一提供者的边断了就是断头路。

7 个无门槛地点是 pier / tavern / chapel / market_row / breakwater / fog_gate / dawn_quay。它们不是「漏写条件」,而是三夜里的公共入口:每一夜的第一个场景都从这里起,玩家不会因为某个 flag 没拿到就卡在原地。

规则:可检查性最低的一类

rules 是四条自然语言,rule_curfew、rule_bell、rule_guild、rule_church。脚本只能做一件事:看规则 id 去掉 rule_ 之后的主题词能不能直接绑定到一个世界实体。结果 2 条能(guild、church 是派系 id),2 条不能(curfew、bell 不是实体 id)。

规则主题词能否绑定实体要判定必须先补什么
rule_curfewcurfew否读法表:把「灯塔区不可进入」读成对灯塔区节点进入条件的约束
rule_bellbell否读法表:把「非大难不得鸣」读成设置 bell_rung 的选项的前置约束
rule_guildguild是(派系)读法表:把「不渡无契约之人」读成授予船帮物品的选项的前置约束
rule_churchchurch是(派系)读法表:把「不承认灯塔之光」读成教会侧选项的禁则

「绑定到实体」只解决了指称问题,没解决判定问题。一条规则要能自动检查,必须被读成条件文法里的一个约束——这个读法由谁写、写在哪、换一种读法结论会不会变,是 3.2 的正题。这里先把结论说清:rules 是 world 段里唯一不能直接判定的字段,任何声称「规则已校验」的说法,都得先交代读法。

自检表

检查项合格标准本案例
取值域stance ∈ {-1,0,+1},残差 0越界残差 0
对角线恒为 0,残差 0对角线残差 0
口径写死反对称与对称都跑,不用「看起来」反对称 2/6、对称 6/6
解锁命名空间每个原子落在 state 里7 个全部 ok
解锁提供者每个条件至少 1 个提供者0 个零提供者
规则诚实不宣称自然语言规则已校验4 条里 2 条待读法表

三组字段读完了:矩阵可判、解锁可查、规则待读。下一步就是拿这三样去做真正的一致性检查。

阅读导航:上一节:2.3 规格的版本与迁移 · 下一节:3.2 设定的一致性:冲突检测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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