9.2 状态变量

三类状态(flag 布尔 18 个、好感度整数 4 个、物品 9 个)与四个效果算子的写入域一一对应:set 只写 flag、add 只写好感度、grant 与 revoke 只写物品。S 层脚本逐条校验越域并统计每个变量的被写次数,实测 138 条写入全部落在域内,revoke 一次未被使用。

9.2 状态变量

独立游戏的叙事设计与分支对话 里也有一份「状态」:好感度、已解锁的线索、见过的 NPC,通常写成一张散文式的条件清单——哪些选择会加谁的好感,靠人记住。本节把那张清单变成一套带写入域约束的可校验状态系统:先声明有哪三类状态,再规定四个效果算子各自能写哪一类,最后用脚本逐条验证规格里的每一次写入都落在自己的域内。

三类状态

状态不是一袋随便什么变量,而是三个命名空间,各自有明确的取值范围:

类别名字空间数量取值语义
旗标state.flags18{0, 1}布尔事实:见过谁、知道什么、做过什么
好感度state.vars40–100 整数连续量:与四个角色的关系深浅
物品state.items9{0, 1}持有:线索、信物、钥匙

三类里只有好感度是连续量,也只有它需要夹取。旗标与物品都是布尔的,声明成整数只是为了让状态推进走同一条整数路径——布尔值一旦写成整数,跨语言对照就不必再处理 true / false 的序列化差异。

好感度取 0–100,四个角色(yan / hui / shen / mu)的初值分别是 20 / 50 / 50 / 50。这里有一个边界值得指出:六个角色都声明了 affinity 段,但 state.vars 只列了四个——lin 与 hai 不在其中。这不是本节要修的问题,而是第 13 章一致性校验的一类真实输入;本节只需要说明它为什么和写入域有关:add 的写入域由 state.vars 定义,所以这两个角色在规格里从未被写过好感度,任何想读它们好感度的条件都会读到 0。

四个算子的写入域

效果算子只有四个。每个算子只允许写一类状态,这是本节唯一的规则表:

算子写入域语义例被写次数
setflag把旗标置为 0 或 1{"set": {"knows_tide_rule": 1}}56
add好感度在当前值上增减(结果夹到 0–100){"add": {"hui": 5}}66
grant物品把物品置为持有{"grant": {"lantern": 1}}16
revoke物品把物品置为未持有{"revoke": {"rope_knot": 1}}0

goto 不在这张表里,因为它不是效果算子:它是 choice 的独立字段,管的是「跳去哪」,而效果算子管的是「状态怎么变」。把跳转混进效果里,状态推进就不再是纯函数了——跳转改的是控制流,不是数据。

为什么写入域要收窄

如果四个算子都能写任何一类状态,写错类型就只有到运行时才看得出来:{"add": {"knows_tide_rule": 1}} 会被当成「旗标加 1」,{"set": {"hui": 5}} 会把好感度直接覆盖成 5。收窄到「一个算子一类状态」之后,这些错误在静态阶段就能判定——不需要跑任何一条路径,只要看写入目标的命名空间。

收窄还有第二个好处:读写的对称性。9.1 的条件叶子只能读三类状态,四个算子只能写这三类状态,读写两侧用的是同一个命名空间集合。于是「条件里出现的名字」与「效果里出现的名字」可以放在同一个集合里比对,这正是 9.3 闭包检验的基础。

S 层:越域检查与写入次数统计

下面的脚本做四件事:① 逐条检查每次写入是否落在算子对应的域内;② 检查值的形状(旗标与物品必须是布尔,好感度增量必须是整数);③ 统计每个变量被写多少次、哪些变量从未被写、哪些算子从未被用;④ 用有界随机路径走一遍,断言好感度全程落在 0–100。越域即非零退出。

#!/usr/bin/env python3
# file: sim/na_09_2_state_variables.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_09_2_state_variables.py
"""卷一 9.2 状态变量(S 层):三类状态与四个效果算子的写入域约束。

三类状态:
    flag  布尔(取值 {0,1})      18 个
    aff   好感度整数(0–100)      4 个
    item  物品持有(取值 {0,1})   9 个

四个算子各有一个写入域,越域即 FAIL:
    set    → flag
    add    → aff
    grant  → item
    revoke → item

本脚本逐条校验「所有效果的写入目标都落在各自声明的域内」,统计每个变量被写多少次、
哪些变量从未被写、哪些算子从未被用,并用有界随机路径走一遍断言好感度全程落在 [0,100]。
产出 out/narr09_state_domain.json。越域或数值越界即非零退出。
"""
from __future__ import annotations

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/narr09_state_domain.json")

AFF_LO, AFF_HI = 0, 100
BOOL_VALUES = {0, 1}
WALK_PATHS, WALK_STEPS, SEED = 2000, 60, 20261016

# 算子 → 写入域。这是本节唯一的规则表,也是越域检查的全部依据。
OPERATOR_DOMAIN = {"set": "flag", "add": "aff", "grant": "item", "revoke": "item"}
DOMAIN_OF_NAMESPACE = {"flag": "flags", "aff": "vars", "item": "items"}


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 apply_effects(eff: dict, st: dict) -> dict:
    """纯函数推进:set→flag,add→aff(夹到 [0,100]),grant/revoke→item。"""
    aff, flags, items = dict(st["aff"]), dict(st["flags"]), dict(st["items"])
    for k in sorted((eff.get("set") or {})):
        flags[k] = eff["set"][k]
    for k in sorted((eff.get("add") or {})):
        v = aff.get(k, 0) + eff["add"][k]
        aff[k] = AFF_LO if v < AFF_LO else AFF_HI if v > AFF_HI else v
    for k in sorted((eff.get("grant") or {})):
        items[k] = eff["grant"][k]
    for k in sorted((eff.get("revoke") or {})):
        items[k] = 0
    return {"aff": aff, "flags": flags, "items": items}


def collect_writes(spec: dict) -> list:
    """摊平全部效果写入:(算子, 键, 值, 出处)。"""
    rows = []
    for n in spec["dialogue"]:
        for ch in (n.get("choices") or []):
            eff = ch.get("effects") or {}
            for op in sorted(OPERATOR_DOMAIN):
                for k, v in (eff.get(op) or {}).items():
                    rows.append({"op": op, "key": k, "value": v, "where": f"{n['id']}/{ch['id']}"})
    return rows


def main() -> int:
    spec = load_spec()
    flags = list(spec["state"]["flags"])
    vars_ = list(spec["state"]["vars"])
    items = list(spec["state"]["items"])
    declared = {"flags": set(flags), "vars": set(vars_), "items": set(items)}
    problems: list[str] = []

    rows = collect_writes(spec)

    # ---- 1) 写入域越界检查 ----
    for r in rows:
        want = DOMAIN_OF_NAMESPACE[OPERATOR_DOMAIN[r["op"]]]
        if r["key"] not in declared[want]:
            problems.append(f"{r['where']} 的 {r['op']} 写入 {r['key']},不在 {want} 域内")

    # ---- 2) 值的形状:flag / item 必须布尔,aff 增量必须是整数 ----
    for r in rows:
        if r["op"] in ("set", "grant", "revoke") and r["value"] not in BOOL_VALUES:
            problems.append(f"{r['where']} 的 {r['op']} 值 {r['value']} 不是布尔")
        if r["op"] == "add" and not isinstance(r["value"], int):
            problems.append(f"{r['where']} 的 add 增量 {r['value']} 不是整数")

    # ---- 3) 逐算子 / 逐变量统计 ----
    op_total = {op: 0 for op in OPERATOR_DOMAIN}
    per_key: dict[str, int] = {}
    for r in rows:
        op_total[r["op"]] += 1
        per_key[r["key"]] = per_key.get(r["key"], 0) + 1

    def unwritten(names: list) -> list:
        return [n for n in names if per_key.get(n, 0) == 0]

    never_written = {"flags": unwritten(flags), "vars": unwritten(vars_), "items": unwritten(items)}
    unused_ops = [op for op in sorted(OPERATOR_DOMAIN) if op_total[op] == 0]

    # ---- 4) 好感度不变量:有界随机路径上全程落在 [0,100] ----
    by_id = {n["id"]: n for n in spec["dialogue"]}
    start = spec["meta"]["start"]
    init = {
        "aff": {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v) for v in sorted(vars_)},
        "flags": {f: 0 for f in sorted(flags)},
        "items": {i: 0 for i in sorted(items)},
    }
    rng = SEED

    def rnd(mod: int) -> int:
        nonlocal rng
        rng = (rng * 48271) % 2147483647
        return rng % mod

    lo_seen = {v: AFF_HI for v in vars_}
    hi_seen = {v: AFF_LO for v in vars_}
    steps_walked = 0
    for _ in range(WALK_PATHS):
        node, st = start, init
        for _step in range(WALK_STEPS):
            cur = by_id[node]
            if cur.get("ending"):
                break
            opts = [c for c in (cur.get("choices") or []) if eval_cond(c.get("when"), st)]
            if not opts:
                break
            picked = opts[rnd(len(opts))]
            st = apply_effects(picked.get("effects") or {}, st)
            node = picked["goto"]
            steps_walked += 1
            for v in vars_:
                if st["aff"][v] < lo_seen[v]:
                    lo_seen[v] = st["aff"][v]
                if st["aff"][v] > hi_seen[v]:
                    hi_seen[v] = st["aff"][v]
    for v in vars_:
        if not (AFF_LO <= lo_seen[v] and hi_seen[v] <= AFF_HI):
            problems.append(f"好感度 {v} 走出 [0,100]:{lo_seen[v]}–{hi_seen[v]}")

    report = {
        "ok": not problems,
        "problems": problems,
        "domains": {"flags": len(flags), "vars": len(vars_), "items": len(items)},
        "operator_domain": OPERATOR_DOMAIN,
        "writes_total": len(rows),
        "writes_by_op": op_total,
        "writes_per_key": dict(sorted(per_key.items())),
        "never_written": never_written,
        "unused_operators": unused_ops,
        "affinity_walk": {"paths": WALK_PATHS, "steps": steps_walked, "lo": lo_seen, "hi": hi_seen},
    }
    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")

    print(f"域规模:flag={len(flags)}  aff={len(vars_)}  item={len(items)}")
    print(f"算子写入域:{OPERATOR_DOMAIN}")
    print(f"效果写入共 {len(rows)} 条:set={op_total['set']}  add={op_total['add']}  "
          f"grant={op_total['grant']}  revoke={op_total['revoke']}")
    for dom, names in (("flag", flags), ("aff", vars_), ("item", items)):
        cnts = ", ".join(f"{n}={per_key.get(n, 0)}" for n in names)
        print(f"  {dom} 写入次数:{cnts}")
    print(f"从未被写:flag={never_written['flags']}  aff={never_written['vars']}  item={never_written['items']}")
    print(f"从未被用的算子:{unused_ops or '(无)'}")
    print(f"随机走 {WALK_PATHS} 条路径共 {steps_walked} 步,好感度实测区间:"
          f"{ {v: [lo_seen[v], hi_seen[v]] for v in vars_} }")
    for p in problems:
        print(f"FAIL {p}")
    if problems:
        return 1
    print("OK na_09_2_state_variables:全部写入落在各自声明的域内,好感度全程在 [0,100]")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/na_09_2_state_variables.py:

域规模:flag=18  aff=4  item=9
算子写入域:{'set': 'flag', 'add': 'aff', 'grant': 'item', 'revoke': 'item'}
效果写入共 138 条:set=56  add=66  grant=16  revoke=0
  flag 写入次数:knows_yan_missing=1, knows_tide_rule=3, knows_bell_legend=5, met_hui=3, met_shen=3, met_mu=3, met_lin=3, met_hai=2, hui_trusts=3, shen_trusts=5, mu_trusts=3, saw_lighthouse_light=7, found_yan_journal=5, cave_explored=2, bell_rung=2, promised_hai=3, has_lantern=1, dawn_choice_made=2
  aff 写入次数:yan=23, hui=13, shen=18, mu=12
  item 写入次数:lantern=2, journal=2, tide_table=1, bell_key=1, salt_letter=2, fog_glass=2, rope_knot=2, prayer_bead=2, ledger_page=2
从未被写:flag=[]  aff=[]  item=[]
从未被用的算子:['revoke']
随机走 2000 条路径共 50840 步,好感度实测区间:{'yan': [0, 90], 'hui': [10, 92], 'shen': [45, 100], 'mu': [25, 100]}
OK na_09_2_state_variables:全部写入落在各自声明的域内,好感度全程在 [0,100]

判读:三条读数

第一,越域为 0。 138 条写入全部落在算子对应的域内,没有任何一条写错命名空间。这正是收窄写入域想换来的结果:这个检查不需要枚举任何一条路径,纯静态就能判。

第二,revoke 一次没用。 四个算子里三个在用(56 / 66 / 16),第四个是 0。这不是缺陷:本案例的物品只增不减,没有任何一处需要收回一件线索。和 9.1 里 or 的情况一样,声明了没用 ≠ 用不了——写入域的规则已经覆盖 revoke,它只是还没被这个案例用到。

第三,写入分布很不均匀。 旗标里 saw_lighthouse_light 被写 7 次、knows_yan_missing 只被写 1 次;好感度里 yan 被写 23 次、hui 只被写 13 次。写次数多不表示这个变量更重要,它只表示有多少条路径经过会改它的选项。这类计数是纯结构量,可以用来说明「哪条线的状态变化最密」,但不能用来声称「哪条线更好」。

最后一行是那趟随机游走。2000 条路径共 50840 步,四个好感度变量的实测区间是 yan [0, 90]、hui [10, 92]、shen [45, 100]、mu [25, 100]。两端都碰到了边界——yan 触到 0,shen 与 mu 触到 100——说明夹取在真实路径上确实生效。但要诚实说明这个区间的性质:它是2000 条随机路径上的实测范围,不是可达状态里的真实上下界。真实上下界要枚举完整状态空间才知道,那是 9.3 的闭包检验顺手给出的一个副产品。

自检表

检查项合格标准
写入域唯一每个算子只对应一类状态,不出现「既能写 flag 又能写物品」的算子
越域可静态判越域检查不依赖路径枚举,只看写入目标的命名空间
值形状受检旗标与物品的写入值是布尔,好感度增量是整数
夹取有证据好感度区间由实跑路径给出,且说明它只是样本范围
跳转不入效果goto 是 choice 的独立字段,不参与状态推进

写入域规定了「谁能写谁」,但还没回答「每个条件读到的名字,是不是真的被写过」。一个从来没被任何效果写过的旗标,读它永远读到 0——引用它的条件就恒假。下一节把这个检查和死条件检测一起做完。

阅读导航:上一节:9.1 条件表达式:旗标、数值、持有 · 下一节:9.3 条件与状态的闭包检验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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