3.1 派系、地点与规则
游戏叙事设计概览
用 93 行散文谈了「世界观设定为什么不能只写在文档里」,那是散文式、不可跑的概论;本节把世界观里最像「设定」的三类东西——派系、地点、规则——写成可校验的字段,并让一个 S 层脚本去判定它们自洽不自洽。story_spec.json 的 world 段就是这三类加一条 lore,全部是结构,没有一句描述性文字参与判定。
世界观在规格里的四个字段
| 字段 | 形态 | 可检查程度 | 谁消费 |
|---|---|---|---|
factions[].stance | 4×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_curfew | curfew | 否 | 读法表:把「灯塔区不可进入」读成对灯塔区节点进入条件的约束 |
rule_bell | bell | 否 | 读法表:把「非大难不得鸣」读成设置 bell_rung 的选项的前置约束 |
rule_guild | guild | 是(派系) | 读法表:把「不渡无契约之人」读成授予船帮物品的选项的前置约束 |
rule_church | church | 是(派系) | 读法表:把「不承认灯塔之光」读成教会侧选项的禁则 |
「绑定到实体」只解决了指称问题,没解决判定问题。一条规则要能自动检查,必须被读成条件文法里的一个约束——这个读法由谁写、写在哪、换一种读法结论会不会变,是 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 设定的一致性:冲突检测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。