12.2 条件文本与变体

同一条台词记录在不同状态下渲染出不同变体,这是条件文本的全部含义。本节枚举两个条件节点的整个定义域:d_tc_2 两分支两条文本;d_lt_2 声明了 line_when 却没写 line_else,101 个状态枚举下来只得到一条文本——结构分流了,文本没分流。变体退化因此成了一个可数的缺口,修法是在规格里补声明,而不是改台词。

《孢子三部曲创作手记》 做的是文本创作本身——把一段台词写得有味道、有分寸。本节与它划界明确:本套不评价文本写得好不好,只做文本的结构。这一节处理的是结构里最能体现「文本是渲染产物」的一层——条件文本:同一条规格记录,配不同的状态,渲染出不同的变体。

上一节把模板与变量立了起来,但模板替换的是同一处文本里的值;条件文本换掉的是整条文本。两者合起来才是完整的文本层:变量管「洞填什么」,条件管「用哪张模板」。条件文本的边界同样写死在上一节那张「做 / 不做」表里:本套只做「一条记录按状态分流成几条文本」这个结构问题,不做「哪一条台词更好」这个创作问题。

12.2 条件文本与变体

一条 dialogue 记录的文本层由三个字段组成:line、line_when、line_else。它们不是台词,是渲染参数。渲染规则只有四条分支,与 1.3 节同一口径:

line_whenline_else渲染哪一条分支名
为 null无所谓linebase
为真无所谓linewhen
为假存在line_elseelse
为假不存在linefallback

「变体」就是这四条分支在整个条件定义域上跑一遍之后,渲染结果里有多少种不同的文本。定义域由条件里的自由变量决定:flag 与 item 是布尔,取 0 与 1;好感度是 0–100 的整数。枚举上界因此是可控的——本规格里两个条件节点的定义域分别是 2 与 101。

规格里恰好有一对互补的样本:

节点line_whenline_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 文本表与单页预览 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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