8.1 好感度的状态机

好感度是一台受钳位约束的整数状态机:状态 = 节点 + 4 个好感度整数 + 18 个 flag + 9 个物品。本节把《雾港夜航》的完整状态空间 BFS 一遍,实测四个变量的可达值域分别是 yan 21、hui 35、shen 12、mu 16,值域之积 141120,而真正可达的好感度四元组只有 87190——四个变量之间并不独立。P 层用同一批选项断言初值来源、add 累加与钳位。

8.1 好感度的状态机

互动叙事中的好感度设计 那篇讲的是设计经验:好感度该分几段、怎么让玩家感知到它、怎么防止刷分。本节往下沉一层,讲机件:当好感度被写成 4 个整数变量之后,它到底是一台什么样的状态机、能走到哪些值、哪些值其实永远走不到。经验回答「设计得好不好」,本节回答「这台机器能走到哪」。

《雾港夜航》把 4 个角色的好感度声明成 state.vars 里的 4 个整数:yan(砚)、hui(徽)、shen(沈)、mu(沐)。声明域是每变量 [0,100],也就是 101 个取值。如果四个变量彼此独立,组合数该是 101^4 = 104060401。本节要验证的正是这个「如果」。

状态 = 位置 + 数值 + 布尔

这台机器的状态由四段拼成:当前停在哪一个对话节点、4 个好感度整数、18 个布尔 flag、9 个物品。转移是一条「可用」的选项——它的 when 在当前状态下求值为真,应用它的 effects,再跳到 goto。

四类写入里只有一类碰好感度:add 累加后钳到 [0,100];set 只写 flag,grant / revoke 只写物品。所以好感度是这台机器里唯一的数值维度,其余全是位置或布尔——这既是它好推理的原因,也是本节能把它整个枚举出来的原因。

四个变量与它们的初值

变量角色初值声明区间声明的分段门
yan砚20[0,100]30 / 60 / 85
hui徽50[0,100]30 / 60 / 85
shen沈50[0,100]30 / 60 / 85
mu沐50[0,100]30 / 60 / 85

注意:characters[] 里其实声明了 6 个角色——lin(林,初值 40)与 hai(孩,初值 60)也有 affinity 段,但它们不在 state.vars 里。这不是 bug,是「声明未用」:它们的 init 与 range 写了,却没有任何条件或效果引用它们。8.3 会把这条当报告项,不当失败。

值域远小于声明域:把状态空间 BFS 一遍

enumerate_states 从起点 d_pier_1 出发做完整 BFS。为了把 989 万个状态塞进内存,状态被打包成一个整数:节点占 6 位、4 个好感度各占 7 位(0–100 够用)、18 个 flag 占 18 位、9 个物品占 9 位。下面是脚本本体。

#!/usr/bin/env python3
# file: sim/ne_08_1_state_machine.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_08_1_state_machine.py
"""卷二 8.1 好感度的状态机(S 层):把好感度当成一个受钳位约束的整数状态机。

状态 = (当前节点, 4 个好感度整数, 18 个 flag, 9 个物品);转移 = 一条可用选项:求值
when → 应用 effects(set 写 flag、add 累加好感度后钳到 [0,100]、grant/revoke 写物品)
→ 跳到 goto。好感度是这台机器里唯一的**数值**维度,其可达值域远小于声明域 0–100。

enumerate_states 是本章公用的完整 BFS 机件(8.3 直接复用):状态打包成单个整数
(节点 6 位 / 好感度 4×7 位 / flag 18 位 / item 9 位)以压低峰值内存,可选 hooks 在
遍历中逐状态 / 逐转移回调。只读 config/story_spec.json,产出 out/ne_08_1_state_machine.*。
"""
from __future__ import annotations

import csv
import json
import os
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
AFF_LO, AFF_HI = 0, 100
STATE_BUDGET = 12000000
SH_IB, SH_FB, SH_AFF = 9, 18, 28
SH_NODE = SH_IB + SH_FB + SH_AFF


def clamp(v: int, lo: int = AFF_LO, hi: int = AFF_HI) -> int:
    return lo if v < lo else hi if v > hi else v


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


def _comp(c, AI, FI, II):
    """把条件编译成带下标的元组,避免每次求值都查名字。"""
    if not c:
        return None
    k = c[0]
    if k == "aff":
        return ("a", AI[c[1]], c[2], c[3])
    if k == "flag":
        return ("f", FI[c[1]], c[2])
    if k == "item":
        return ("i", II[c[1]], c[2])
    if k == "and":
        return ("&", _comp(c[1], AI, FI, II), _comp(c[2], AI, FI, II))
    if k == "or":
        return ("|", _comp(c[1], AI, FI, II), _comp(c[2], AI, FI, II))
    return ("!", _comp(c[1], AI, FI, II))


def _ev(c, aff, fb, ib) -> bool:
    if c is None:
        return True
    t = c[0]
    if t == "a":
        v, op, n = aff[c[1]], c[2], c[3]
        return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
    if t == "f":
        return ((fb >> c[1]) & 1) == c[2]
    if t == "i":
        return ((ib >> c[1]) & 1) == c[2]
    if t == "&":
        return _ev(c[1], aff, fb, ib) and _ev(c[2], aff, fb, ib)
    if t == "|":
        return _ev(c[1], aff, fb, ib) or _ev(c[2], aff, fb, ib)
    return not _ev(c[1], aff, fb, ib)


def context(spec: dict) -> tuple:
    """编译 spec:返回 (VARS, AI, nodes, NI, choices, encode, decode)。"""
    VARS = sorted(spec["state"]["vars"])
    FLAGS = sorted(spec["state"]["flags"])
    ITEMS = sorted(spec["state"]["items"])
    AI = {v: i for i, v in enumerate(VARS)}
    FI = {v: i for i, v in enumerate(FLAGS)}
    II = {v: i for i, v in enumerate(ITEMS)}
    nodes = spec["dialogue"]
    NI = {n["id"]: i for i, n in enumerate(nodes)}
    choices = []
    for n in nodes:
        cs = []
        for c in (n.get("choices") or []):
            e = c.get("effects") or {}
            cs.append((_comp(c.get("when"), AI, FI, II),
                       [(FI[k], v) for k, v in (e.get("set") or {}).items()],
                       [(AI[k], v) for k, v in (e.get("add") or {}).items()],
                       [(II[k], v) for k, v in (e.get("grant") or {}).items()],
                       [II[k] for k in (e.get("revoke") or {})],
                       NI[c["goto"]]))
        choices.append(cs)

    def encode(ni: int, aff, fb: int, ib: int) -> int:
        code = aff[0] | (aff[1] << 7) | (aff[2] << 14) | (aff[3] << 21)
        return (ni << SH_NODE) | (code << SH_IB << SH_FB) | (fb << SH_IB) | ib

    def decode(key: int) -> tuple:
        ib = key & 511
        fb = (key >> SH_IB) & ((1 << SH_FB) - 1)
        code = (key >> (SH_IB + SH_FB)) & ((1 << SH_AFF) - 1)
        aff = (code & 127, (code >> 7) & 127, (code >> 14) & 127, (code >> 21) & 127)
        return key >> SH_NODE, aff, fb, ib

    return VARS, AI, nodes, NI, choices, encode, decode


def enumerate_states(spec: dict, on_state=None, on_transition=None, budget: int = STATE_BUDGET) -> dict:
    """完整 BFS。on_state(ni, aff, fb, ib);on_transition(ni, aff, fb, ib, ci, sa, sf, si)。"""
    VARS, AI, nodes, NI, choices, encode, decode = context(spec)
    aff0 = tuple(next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v) for v in VARS)
    start = encode(NI[spec["meta"]["start"]], aff0, 0, 0)
    seen = {start}
    frontier = [start]
    states = transitions = 0
    hit = False
    while frontier:
        nxt: list = []
        for key in frontier:
            ni, aff, fb, ib = decode(key)
            states += 1
            if on_state:
                on_state(ni, aff, fb, ib)
            if nodes[ni].get("ending"):
                continue
            for ci, (cw, sl, al, gl, rl, gi) in enumerate(choices[ni]):
                if not _ev(cw, aff, fb, ib):
                    continue
                transitions += 1
                a = list(aff)
                for i, d in al:
                    a[i] = clamp(a[i] + d)
                f2 = fb
                for i, v in sl:
                    f2 = (f2 | (1 << i)) if v else (f2 & ~(1 << i))
                i2 = ib
                for i, v in gl:
                    i2 = (i2 | (1 << i)) if v else (i2 & ~(1 << i))
                for i in rl:
                    i2 &= ~(1 << i)
                if on_transition:
                    on_transition(ni, aff, fb, ib, ci, a, f2, i2)
                nk = encode(gi, a, f2, i2)
                if nk not in seen:
                    seen.add(nk)
                    nxt.append(nk)
        frontier = nxt
        if states > budget:
            hit = True
            break
    return {"states": states, "transitions": transitions, "budget_hit": hit,
            "VARS": VARS, "nodes": nodes, "choices": choices}


def main() -> int:
    t0 = time.time()
    spec = load_spec()
    VARS, AI, nodes, NI, choices, encode, decode = context(spec)
    aff_vals = [set() for _ in VARS]
    aff_tuples: set = set()

    def on_state(ni, aff, fb, ib):
        for i in range(len(VARS)):
            aff_vals[i].add(aff[i])
        aff_tuples.add(aff[0] | (aff[1] << 7) | (aff[2] << 14) | (aff[3] << 21))

    r = enumerate_states(spec, on_state=on_state)
    add_cnt = {v: 0 for v in VARS}
    add_total = 0
    for n in nodes:
        for c in (n.get("choices") or []):
            for k in ((c.get("effects") or {}).get("add") or {}):
                add_total += 1
                if k in add_cnt:
                    add_cnt[k] += 1
    declared = {c["id"]: c["affinity"] for c in spec["characters"]}
    distinct = [len(s) for s in aff_vals]
    prod = 1
    for d in distinct:
        prod *= d
    reach_aff = len(aff_tuples)
    space = 101 ** len(VARS)

    print(f"spec={spec['meta']['title']}  变量 {VARS}  起点 {spec['meta']['start']}")
    print(f"可达状态 {r['states']}(完整 BFS,预算 {STATE_BUDGET},超限={r['budget_hit']})  转移 {r['transitions']}")
    print("好感度状态机(声明域每变量 0–100 共 101 个取值):")
    print(f"  {'变量':<6}{'init':>5}{'add 次数':>9}{'取到不同值':>10}   实测区间")
    rows = []
    for i, v in enumerate(VARS):
        s = aff_vals[i]
        lo, hi = min(s), max(s)
        print(f"  {v:<6}{declared[v]['init']:>5}{add_cnt[v]:>9}{len(s):>10}   [{lo}, {hi}]")
        rows.append({"var": v, "init": declared[v]["init"], "add_count": add_cnt[v],
                     "distinct": len(s), "min": lo, "max": hi})
    print(f"add 写入合计 {add_total} 次(" + "  ".join(f"{v}×{add_cnt[v]}" for v in VARS) + ")")
    print(f"各变量值域大小之积 {'×'.join(str(d) for d in distinct)} = {prod}")
    print(f"实测可达好感度组合数 {reach_aff}(值域之积 {prod},相等={reach_aff == prod})")
    print(f"声明空间 101^{len(VARS)} = {space};可达组合占 {reach_aff * 1000000 // space} 每百万")
    bad = [VARS[i] for i, s in enumerate(aff_vals) if min(s) < AFF_LO or max(s) > AFF_HI]
    print(f"I1 全部可达好感度 ∈ [0,100]:{not bad}")
    assert r["states"] > 0 and not r["budget_hit"] and not bad

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "ne_08_1_state_machine.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=["var", "init", "add_count", "distinct", "min", "max"])
        w.writeheader()
        w.writerows(rows)
    with open(os.path.join(OUT, "ne_08_1_state_machine.json"), "w", encoding="utf-8") as fh:
        json.dump({"states": r["states"], "transitions": r["transitions"], "add_total": add_total,
                   "vars": rows, "product": prod, "reachable_affinity_tuples": reach_aff,
                   "cartesian_product": reach_aff == prod, "declared_space": space,
                   "elapsed_s": round(time.time() - t0, 1)}, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print("wrote out/ne_08_1_state_machine.csv / .json")
    print("OK ne_08_1_state_machine:状态机已枚举,好感度值域远小于声明域")
    return 0


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

本机实跑(约 22 秒):

spec=雾港夜航  变量 ['hui', 'mu', 'shen', 'yan']  起点 d_pier_1
可达状态 9895423(完整 BFS,预算 12000000,超限=False)  转移 12163825
好感度状态机(声明域每变量 0–100 共 101 个取值):
  变量     init   add 次数     取到不同值   实测区间
  hui      50       13        35   [10, 97]
  mu       50       12        16   [25, 100]
  shen     50       18        12   [45, 100]
  yan      20       23        21   [0, 100]
add 写入合计 66 次(hui×13  mu×12  shen×18  yan×23)
各变量值域大小之积 35×16×12×21 = 141120
实测可达好感度组合数 87190(值域之积 141120,相等=False)
声明空间 101^4 = 104060401;可达组合占 837 每百万
I1 全部可达好感度 ∈ [0,100]:True
wrote out/ne_08_1_state_machine.csv / .json
OK ne_08_1_state_machine:状态机已枚举,好感度值域远小于声明域

结论有两层。第一层是值域远小于声明域:yan 只取到 21 个不同的值、hui 35 个、shen 12 个、mu 16 个,没有任何变量取满 101 个。

第二层更重要——四个变量并不独立。把各自的值域大小相乘得 35×16×12×21 = 141120,但真正可达的好感度四元组只有 87190 个。141120 是「每个变量各自能取的值」的笛卡尔积上界,87190 才是「四个变量同时成立」的实际计数。差距来自耦合:yan 的每一次 add 都发生在某个具体节点上,而到达那个节点本身就已经约束了另外三个变量的范围。所以第 5 章给出的 141120 只能当上界读,不能当可达组合数读。

P 层:初值、用量、累加与钳位

S 层枚举的是「能走到哪」,P 层把同一批选项钉在类型层面,断言四件事:初值确实来自 characters[].affinity.init;add 一共 66 次且分得清每条变量的用量;add 是累积的(同一变量两次写入会叠加);累加之后确实被钳回 [0,100]。

add 合计 66:yan×23 hui×13 shen×18 mu×12
✔ 初值来自 characters[].affinity.init:yan=20 hui=50 shen=50 mu=50 (0.987792ms)
✔ add 用量:共 66 次,yan×23 shen×18 hui×13 mu×12 (0.571209ms)
✔ add 是累积的:同一变量两次写入叠加(50 → +5 → +10) (0.092708ms)
✔ 累加后钳到 [0,100]:人为放大百万级仍被钳回 (0.065959ms)
✔ 转移是纯函数:不改入参、同输入同输出;set 不碰好感度 (0.164416ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0

五条全过。最后一条是负例:把 add 的增量放大到 1000000,断言结果仍是 100;改成 -1000000 则断言仍是 0。它证明的不是实现正确,而是钳位确实在执行——哪次重构漏掉钳位,它会立刻红。

自检表

检查项合格标准
状态打包无损节点 / 好感度 / flag / item 四段拼成一个整数,BFS 去重不误判
值域实测4 个变量的可达值域分别为 21 / 35 / 12 / 16,均小于 101
组合数诚实可达四元组 87190 ≠ 值域之积 141120,如实报告耦合
钳位有负例增量放大到百万级仍被钳回 [0,100]
声明未用不判 FAILlin / hai 有 affinity 声明但无引用,只报告

值域、初值、钳位都定住了。但好感度不是拿来看的,是拿来开门的:某条选项要 yan >= 30 才能选。下一节把这些门限全抽出来,再逐条扰动 ±5,看哪几扇门是真正承重的。

阅读导航:上一节:7.3 组合爆炸的控制 · 下一节:8.2 阈值门与门限扰动 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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