《孢子三部曲创作手记》 做的是文本创作本身——把一段台词写得有味道、有分寸。本节与它划界明确:本套不评价文本写得好不好,只做文本的结构。这一节处理的是结构里最能体现「文本是渲染产物」的一层——条件文本:同一条规格记录,配不同的状态,渲染出不同的变体。
上一节把模板与变量立了起来,但模板替换的是同一处文本里的值;条件文本换掉的是整条文本。两者合起来才是完整的文本层:变量管「洞填什么」,条件管「用哪张模板」。条件文本的边界同样写死在上一节那张「做 / 不做」表里:本套只做「一条记录按状态分流成几条文本」这个结构问题,不做「哪一条台词更好」这个创作问题。
12.2 条件文本与变体
一条 dialogue 记录的文本层由三个字段组成:line、line_when、line_else。它们不是台词,是渲染参数。渲染规则只有四条分支,与 1.3 节同一口径:
line_when | line_else | 渲染哪一条 | 分支名 |
|---|---|---|---|
为 null | 无所谓 | line | base |
| 为真 | 无所谓 | line | when |
| 为假 | 存在 | line_else | else |
| 为假 | 不存在 | line | fallback |
「变体」就是这四条分支在整个条件定义域上跑一遍之后,渲染结果里有多少种不同的文本。定义域由条件里的自由变量决定:flag 与 item 是布尔,取 0 与 1;好感度是 0–100 的整数。枚举上界因此是可控的——本规格里两个条件节点的定义域分别是 2 与 101。
规格里恰好有一对互补的样本:
| 节点 | line_when | line_else | 预期 |
|---|---|---|---|
d_tc_2 | ["flag","cave_explored",1] | 有 | 两分支、两条文本 |
d_lt_2 | ["aff","yan",">=",30] | 无 | 两分支、一条文本(欠声明) |
d_lt_2 是规格里唯一一个「声明了条件却没声明另一分支」的节点。它不是缺陷数据,而是规格层面的欠声明——修法是补一条 line_else,不是改台词。
S 层:枚举整个定义域
sim/na_12_2_conditional_variant.py 对每个带 line_when 的节点做三件事:收集条件里的自由变量、按其定义域做笛卡尔积、逐状态渲染并归类到四条分支。输出的两个计数是本节的核心——结构分支数与文本变体数。
#!/usr/bin/env python3
# file: sim/na_12_2_conditional_variant.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_12_2_conditional_variant.py
"""卷一 12.2 条件文本与变体(S 层):同一份规格在不同状态下渲染出多少种文本。
对每个带 line_when 的节点,枚举其条件里**自由变量的整个定义域**
(flag / item 取 {0,1},好感度取 0–100),把状态空间投影成两个计数:
结构分支 = 渲染器实际走到的分支种类数(base / when / else / fallback)
文本变体 = 渲染出的不同文本条数
两者之差就是「结构分流了、文本没分流」的缺口。本规格里 d_tc_2 两分支两条
文本,d_lt_2 两分支只有一条——后者是规格层面的欠声明(缺 line_else)。
比较只在同一进程内做,用于计数;跨语言的摘要只对状态做。
"""
from __future__ import annotations
import itertools
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/story_spec.json")
OUT = os.path.join(ROOT, "out/na_12_2_variant_enum.json")
AFF_LO, AFF_HI = 0, 100
BOOL_DOMAIN = [0, 1]
N_DIALOGUE, N_STATIC = 48, 46
ENUM_CAP = 1024
def load_spec() -> dict:
with open(SPEC, encoding="utf-8") as fh:
return json.load(fh)
def eval_cond(c, st: dict) -> bool:
"""条件求值:六种形式,与 na_00 / 内核同语义。"""
if c is None:
return True
k = c[0]
if k == "flag":
return st["flags"].get(c[1], 0) == c[2]
if k == "item":
return st["items"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = st["aff"].get(c[1], 0), c[2], c[3]
return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
if k == "and":
return eval_cond(c[1], st) and eval_cond(c[2], st)
if k == "or":
return eval_cond(c[1], st) or eval_cond(c[2], st)
if k == "not":
return not eval_cond(c[1], st)
raise ValueError(f"unknown cond op: {k}")
def render_line(node: dict, st: dict) -> tuple[str, str]:
"""四分支渲染规则,与 na_01_line_render.py 同一口径。"""
if node.get("line_when") is None:
return node["line"], "base"
if eval_cond(node["line_when"], st):
return node["line"], "when"
if node.get("line_else"):
return node["line_else"], "else"
return node["line"], "fallback"
def free_vars(c, out: list) -> None:
"""收集条件里的自由变量(去重、保持出现顺序)。"""
if c is None:
return
k = c[0]
if k in ("flag", "item", "aff"):
pair = (k, c[1])
if pair not in out:
out.append(pair)
return
if k == "not":
free_vars(c[1], out)
return
free_vars(c[1], out)
free_vars(c[2], out)
def domain(kind: str) -> list:
return BOOL_DOMAIN if kind in ("flag", "item") else list(range(AFF_LO, AFF_HI + 1))
def base_state(spec: dict) -> dict:
return {
"aff": {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in sorted(spec["state"]["vars"])},
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])},
}
def with_assign(base: dict, vars_: list, values: tuple) -> dict:
st = {"aff": dict(base["aff"]), "flags": dict(base["flags"]), "items": dict(base["items"])}
for (kind, name), val in zip(vars_, values):
if kind == "aff":
st["aff"][name] = val
elif kind == "flag":
st["flags"][name] = val
else:
st["items"][name] = val
return st
def enumerate_node(spec: dict, node: dict, base: dict) -> dict:
"""枚举一个节点的整个条件定义域,返回分支分布与渲染结果。"""
vars_: list = []
free_vars(node["line_when"], vars_)
size = 1
for kind, _ in vars_:
size *= len(domain(kind))
if size > ENUM_CAP:
raise ValueError(f"{node['id']} 枚举上界 {size} 超过 {ENUM_CAP}")
branch_count: dict[str, int] = {}
samples: dict[str, dict] = {}
texts = set()
combos = list(itertools.product(*[domain(k) for k, _n in vars_])) if vars_ else [()]
for values in combos:
st = with_assign(base, vars_, values)
text, branch = render_line(node, st)
branch_count[branch] = branch_count.get(branch, 0) + 1
texts.add(text)
key = ",".join(f"{n}={v}" for (_k, n), v in zip(vars_, values))
prev = samples.get(branch)
if prev is None:
samples[branch] = {"first": key, "last": key, "text": text}
else:
prev["last"] = key
return {"id": node["id"], "line_when": node["line_when"], "vars": [f"{k}:{n}" for k, n in vars_],
"states": size, "branches": dict(sorted(branch_count.items())),
"distinct_texts": len(texts), "samples": samples}
def main() -> int:
spec = load_spec()
dlg = spec["dialogue"]
base = base_state(spec)
cond_nodes = [n for n in dlg if n.get("line_when")]
static_nodes = [n for n in dlg if not n.get("line_when")]
rows = []
for node in cond_nodes:
info = enumerate_node(spec, node, base)
rows.append(info)
print(f"{info['id']} line_when={info['line_when']} 自由变量={info['vars']}")
print(f" 枚举状态 {info['states']} 个 -> 结构分支 {len(info['branches'])} 种 {info['branches']},"
f"文本变体 {info['distinct_texts']} 种")
for branch in sorted(info["samples"]):
s = info["samples"][branch]
print(f" [{s['first']}] {branch}:{s['text']}")
if s["last"] != s["first"]:
print(f" [{s['last']}] {branch}:{s['text']}")
struct_total = len(static_nodes) + sum(len(r["branches"]) for r in rows)
variant_total = len(static_nodes) + sum(r["distinct_texts"] for r in rows)
gap = struct_total - variant_total
print(f"全规格:静态节点 {len(static_nodes)} 个(各 1 分支 1 变体)")
print(f"结构分支合计 {struct_total} 种,文本变体合计 {variant_total} 种,"
f"「结构分流而文本不分流」的缺口 {gap} 处 "
f"{[r['id'] for r in rows if r['distinct_texts'] < len(r['branches'])]}")
report = {"static_nodes": len(static_nodes), "conditional_nodes": len(rows),
"structural_branches": struct_total, "text_variants": variant_total,
"variant_gap": gap, "nodes": rows}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
# ---- 整数不变量 ----
if len(dlg) != N_DIALOGUE or len(static_nodes) != N_STATIC:
print(f"FAIL 节点数 {len(dlg)} / 静态 {len(static_nodes)} 与预期不符")
return 1
for r in rows:
if sum(r["branches"].values()) != r["states"]:
print(f"FAIL {r['id']} 分支计数之和 != 枚举状态数")
return 1
if r["distinct_texts"] > len(r["branches"]):
print(f"FAIL {r['id']} 文本变体多于结构分支")
return 1
print("OK na_12_2_conditional_variant:d_tc_2 两分支两条文本,d_lt_2 两分支一条文本(欠声明)")
return 0
if __name__ == "__main__":
sys.exit(main())
d_tc_2 line_when=['flag', 'cave_explored', 1] 自由变量=['flag:cave_explored']
枚举状态 2 个 -> 结构分支 2 种 {'else': 1, 'when': 1},文本变体 2 种
[cave_explored=0] else:石壁上刻着名字,一个接一个,刻得很浅。
[cave_explored=1] when:石壁深处传来一句话,像有人在念自己刻的字:十七个。你是第十八个来的。
d_lt_2 line_when=['aff', 'yan', '>=', 30] 自由变量=['aff:yan']
枚举状态 101 个 -> 结构分支 2 种 {'fallback': 30, 'when': 71},文本变体 1 种
[yan=0] fallback:你来晚了三天。不过,也不算晚。
[yan=29] fallback:你来晚了三天。不过,也不算晚。
[yan=30] when:你来晚了三天。不过,也不算晚。
[yan=100] when:你来晚了三天。不过,也不算晚。
全规格:静态节点 46 个(各 1 分支 1 变体)
结构分支合计 50 种,文本变体合计 49 种,「结构分流而文本不分流」的缺口 1 处 ['d_lt_2']
OK na_12_2_conditional_variant:d_tc_2 两分支两条文本,d_lt_2 两分支一条文本(欠声明)
判读:两个计数之差就是缺口
d_tc_2 是「同一份规格 → 多种文本」的正面样本。 枚举 2 个状态,走到 2 种结构分支,得到 2 种文本变体:cave_explored=0 时读到的是一句平静的描述,cave_explored=1 时读到的是石壁深处的那句话。规格里那条记录一个字都没变,变的是状态——这正是「文本是渲染产物」在条件维度上的含义。
d_lt_2 是同一个计数法的反面样本。 它的定义域有 101 个取值,结构上确实分流了:yan < 30 的 30 个状态走 fallback,yan >= 30 的 71 个状态走 when。但两条分支渲染出来的是同一条文本——因为规格没写 line_else,渲染器按声明规则回落到 line。于是 101 个状态、2 种分支,收敛到 1 种文本变体。
「缺口 1 处」是这两个计数相减的结果。 全规格 46 个静态节点各贡献 1 种分支与 1 种变体,两个条件节点贡献 4 种分支与 3 种变体:结构分支合计 50,文本变体合计 49。差值 1 就是 d_lt_2——结构分流了,文本没分流。这个数是可回归的:补上 line_else 之后它会变成 0,删掉另一条 line_else 它会变成 2。
注意这里比较的是同一进程内渲染结果的异同,用于计数;跨语言的摘要只对状态做,不对文本做。变体枚举要回答的是「有几种」,不是「哪一种更好」——后者不在本套的范围内。
变体一致性规则
变体不只是「有几条」,还要「互相不自相矛盾」。本节给三条可判定的规则,作为后续一致性校验(第 14 章)的输入:
| 规则 | 判据 | 本规格实测 |
|---|---|---|
| 分支完整 | 有 line_when 就应有 line_else | 违反 1 处(d_lt_2) |
| 变体不退 | 文本变体数应等于结构分支数 | 违反 1 处(d_lt_2) |
| 分支可达 | 每条分支都应在定义域内至少命中一个状态 | 0 处违反 |
三条规则都不涉及文本内容:第一条看字段在不在,第二条看两个计数等不等,第三条看分支计数是否为零。这正是「只做结构」的含义——变体的一致性可以被算出来,而不需要人来读。
自检表
| 检查项 | 合格标准 |
|---|---|
| 渲染规则唯一 | 四分支口径与 1.3 节一致,不在本节另立一套 |
| 枚举有界 | 定义域来自自由变量,枚举规模不超过脚本声明的上界 |
| 缺口可数 | 结构分支数与文本变体数都被打印,差值可回归 |
| 分支计数自洽 | 各分支计数之和等于枚举状态数(脚本内断言) |
| 不做文本评价 | 只数「有几种」,不判断「哪一种更好」 |
下一节把模板与变体一起落成一张表:163 行的文本表,每行一个键,内联进单文件 HTML,用无头浏览器断言它的条数与指纹。
阅读导航:上一节:12.1 模板与变量 · 下一节:12.3 文本表与单页预览 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。