8.3 好感度的不变量

四条必须恒为 0 的残差:钳位(可达状态与转移后都在 [0,100] 内)、初值(每个声明角色的 init 落在自身 range 内)、累积口径(新值恒等于 clamp(旧值 + add),set 不写好感度)、门限可达性(15 条门限均可跨过)。四条残差合计 0;lin 与 hai 有 affinity 声明却不在 state.vars,按「声明未用=报告,用了未声明=FAIL」报告。

8.3 好感度的不变量

互动叙事中的好感度设计 那篇讲的是经验:好感度该在什么节点给、给多少才不显得突兀。本节讲不变量:不管怎么给,有四条底线必须永远成立,本节把每一条都写成可枚举、可断言的残差。

「不变量」这个词听着抽象,落地其实很朴素:某些数必须恒为 0。钳位越界的变量数、初值越出声明区间的角色数、累积口径对不上的变量数、结构性不可达的门限数——这四条只要有一条不为 0,规格就有洞。本节把这四条一次核到底,并把它们写成一条公式。

四条残差的口径

①钳位——全部可达状态、以及每次转移之后,4 个变量都 ∈ [0,100];②初值——每个声明角色的 affinity.init 落在自身 affinity.range 内;③累积口径——每条转移、每个变量,新值必须等于 clamp(旧值 + 该选项对该变量的 add),且 set 不得写好感度;④门限可达性——每条 ["aff", 名, ">=", 整数] 都真的可能被跨过。

前三者复用 8.1 的 enumerate_states:同一趟 BFS 里挂两个回调,一个逐状态查钳位与门限,一个逐转移查累积口径。第四者需要知道每条门限「长在哪个节点」以及「该节点上实测最高能到多少」,两者一减就是缺口。

把四条不变量核一遍

#!/usr/bin/env python3
# file: sim/ne_08_3_invariant.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_08_3_invariant.py
"""卷二 8.3 好感度的不变量(S 层):四条必须恒为 0 的残差。

复用 8.1 的完整 BFS 机件 enumerate_states,逐状态、逐转移核对:

  ① 钳位——全部可达状态、以及每次转移后,4 个变量都 ∈ [0,100];
  ② 初值——每个声明角色的 affinity.init 落在自身 affinity.range 内;
  ③ 累积口径——每条转移、每个变量:新值必须等于 clamp(旧值 + 该选项对该变量的 add);
     set 不得写好感度;
  ④ 阈值门可达性——每条 ["aff", 名, ">=", 整数] 门限都真的可能被跨过。

另如实报告:lin / hai 声明了 affinity 却不在 state.vars,故它们的门限**结构性不可达**;
但实测本规格 0 个条件引用它们。判据写死:**声明未用=报告;用了未声明=FAIL**。
只读 config/story_spec.json,产出 out/ne_08_3_invariant.json。整数口径。
"""
from __future__ import annotations

import json
import os
import sys

import ne_08_1_state_machine as sm

AFF_LO, AFF_HI = 0, 100


def collect_gates(spec: dict) -> list:
    """抽平全部 aff 门限(含嵌在 and/or/not 里的),返回 [{kind,node,choice,var,op,n}]。"""
    gates: list = []

    def walk(c):
        if not c:
            return
        if c[0] == "aff":
            yield c
        elif c[0] == "not":
            yield from walk(c[1])
        elif c[0] in ("and", "or"):
            yield from walk(c[1])
            yield from walk(c[2])

    def add(kind, node, choice, cond):
        for sub in walk(cond):
            gates.append({"kind": kind, "node": node, "choice": choice,
                          "var": sub[1], "op": sub[2], "n": sub[3]})

    for n in spec["dialogue"]:
        add("line_when", n["id"], "-", n.get("line_when"))
        for c in (n.get("choices") or []):
            add("choice", n["id"], c["id"], c.get("when"))
    for e in spec["endings"]:
        add("ending", e["id"], e["id"], e.get("when"))
    return gates


def site(g: dict) -> str:
    return f"{g['node']}/{g['choice']}" if g["choice"] != "-" else f"{g['node']}[{g['kind']}]"


def main() -> int:
    spec = sm.load_spec()
    VARS, AI, nodes, NI, choices, encode, decode = sm.context(spec)
    gates = collect_gates(spec)
    assert all(g["op"] == ">=" for g in gates), "本节门限按 >= 口径统计"
    gnode: dict = {}
    for gi, g in enumerate(gates):
        if g["var"] in AI:
            gnode.setdefault(NI[g["node"]], []).append(gi)
    gsat = [False] * len(gates)
    gmax = [AFF_LO] * len(gates)
    st = {"clamp": 0, "tclamp": 0, "cum": 0}

    def on_state(ni, aff, fb, ib):
        if any(v < AFF_LO or v > AFF_HI for v in aff):
            st["clamp"] += 1
        for gi in gnode.get(ni, ()):
            v = aff[AI[gates[gi]["var"]]]
            if v > gmax[gi]:
                gmax[gi] = v
            if v >= gates[gi]["n"]:
                gsat[gi] = True

    def on_transition(ni, aff, fb, ib, ci, sa, sf, si):
        if any(v < AFF_LO or v > AFF_HI for v in sa):
            st["tclamp"] += 1
        add_by = dict(choices[ni][ci][2])
        for i in range(len(VARS)):
            if sa[i] != sm.clamp(aff[i] + add_by.get(i, 0)):
                st["cum"] += 1

    r = sm.enumerate_states(spec, on_state=on_state, on_transition=on_transition)

    init_bad = [c["id"] for c in spec["characters"]
                if not (c["affinity"]["range"][0] <= c["affinity"]["init"] <= c["affinity"]["range"][1])]
    set_var = sum(1 for n in nodes for c in (n.get("choices") or [])
                  for k in ((c.get("effects") or {}).get("set") or {}) if k in AI)
    used_und: set = set()

    def scan(c):
        if not c:
            return
        if c[0] == "aff":
            if c[1] not in AI:
                used_und.add(c[1])
        elif c[0] in ("and", "or"):
            scan(c[1])
            scan(c[2])
        elif c[0] == "not":
            scan(c[1])

    for n in nodes:
        scan(n.get("line_when"))
        for c in (n.get("choices") or []):
            scan(c.get("when"))
            for k in ((c.get("effects") or {}).get("add") or {}):
                if k not in AI:
                    used_und.add(k)
    for e in spec["endings"]:
        scan(e.get("when"))

    declared_unused = sorted(c["id"] for c in spec["characters"] if c["id"] not in AI)
    dead = [site(gates[gi]) for gi in range(len(gates)) if not gsat[gi]]
    resid = st["clamp"] + st["tclamp"] + len(init_bad) + st["cum"] + len(dead)

    print(f"spec={spec['meta']['title']}  可达状态 {r['states']}(超限={r['budget_hit']})  转移 {r['transitions']}")
    print(f"① 钳位:状态越界 {st['clamp']}   转移后越界 {st['tclamp']}   (全部 ∈ [0,100])")
    print(f"② 初值:{len(spec['characters'])} 个声明角色,越出自身声明区间 {len(init_bad)}")
    print(f"③ 累积口径:转移中「新值 != clamp(旧值 + add)」的变量数 {st['cum']};set 写好感度的键 {set_var}")
    print(f"④ 阈值门可达性:{len(gates)} 条门限,结构性不可达 {len(dead)} 条")
    print("   门限表(出现节点上的实测最高值):")
    print("     位置                             变量  门限   实测最高  缺口")
    for gi, g in enumerate(gates):
        print(f"     {site(g):<32} {g['var']:<4} {g['n']:>3}   {gmax[gi]:>6}   {g['n'] - gmax[gi]:>3}")
    print(f"声明域 > 状态域:{declared_unused}(有 affinity 声明,不在 state.vars;声明未用=报告)")
    print(f"引用未声明好感度变量的条件/效果 {len(used_und)} 处 {sorted(used_und)}(用了未声明=FAIL)")
    print(f"I-ALL 残差 = 钳位 {st['clamp'] + st['tclamp']} + 初值 {len(init_bad)} "
          f"+ 累积 {st['cum']} + 死门 {len(dead)} = {resid}")

    if r["budget_hit"] or used_und or resid:
        print("FAIL 残差非零或引用了未声明变量")
        return 1
    os.makedirs(sm.OUT, exist_ok=True)
    with open(os.path.join(sm.OUT, "ne_08_3_invariant.json"), "w", encoding="utf-8") as fh:
        json.dump({"states": r["states"], "transitions": r["transitions"],
                   "clamp_violations": st["clamp"] + st["tclamp"], "init_violations": len(init_bad),
                   "cumulative_mismatches": st["cum"], "set_var_keys": set_var, "gates": len(gates),
                   "dead_gates": dead, "declared_unused": declared_unused,
                   "used_undeclared": sorted(used_und), "residual": resid},
                  fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print("wrote out/ne_08_3_invariant.json")
    print("OK ne_08_3_invariant:四条残差恒为 0,声明未用已报告,无用了未声明")
    return 0


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

本机实跑:

spec=雾港夜航  可达状态 9895423(超限=False)  转移 12163825
① 钳位:状态越界 0   转移后越界 0   (全部 ∈ [0,100])
② 初值:6 个声明角色,越出自身声明区间 0
③ 累积口径:转移中「新值 != clamp(旧值 + add)」的变量数 0;set 写好感度的键 0
④ 阈值门可达性:15 条门限,结构性不可达 0 条
   门限表(出现节点上的实测最高值):
     位置                             变量  门限   实测最高  缺口
     d_tavern_2/c_tavern_2c           hui   55       57    -2
     d_tavern_3/c_tavern_3b           hui   60       67    -7
     d_chapel_2/c_chapel_2c           shen  55       55     0
     d_chapel_3/c_chapel_3b           shen  60       65    -5
     d_ch_2/c_ch_2c                   mu    55       55     0
     d_wp_2/c_wp_2c                   yan   25       25     0
     d_wp_3/c_wp_3b                   yan   30       35    -5
     d_lb_2/c_lb_2c                   yan   30       35    -5
     d_tc_2/c_tc_2c                   yan   40       45    -5
     d_ho_3/c_ho_3b                   mu    70      100   -30
     d_db_2/c_db_2c                   shen  60       85   -25
     d_lt_2[line_when]                yan   30       75   -45
     d_bw_2/c_bw_2c                   hui   60       82   -22
     d_dq_2/c_dq_2a                   yan   40      100   -60
     e_lit/e_lit                      yan   40      100   -60
声明域 > 状态域:['hai', 'lin'](有 affinity 声明,不在 state.vars;声明未用=报告)
引用未声明好感度变量的条件/效果 0 处 [](用了未声明=FAIL)
I-ALL 残差 = 钳位 0 + 初值 0 + 累积 0 + 死门 0 = 0
wrote out/ne_08_3_invariant.json
OK ne_08_3_invariant:四条残差恒为 0,声明未用已报告,无用了未声明

四条残差全是 0,I-ALL 合计 0。最值得注意的是第四条的缺口列:三条门限的实测最高值恰好等于门限本身(缺口 0)——d_chapel_2/c_chapel_2c(shen >= 55)、d_ch_2/c_ch_2c(mu >= 55)、d_wp_2/c_wp_2c(yan >= 25)。门限只到「刚好够得着」,再高一点就永远跨不过去。

这正好与 8.2 的扰动结果对上:那三条加上 d_tavern_2 就是四条临界门限。缺口为 0 是「刚好可达」,扰动敏感是「一碰就变」——同一批门限从两个角度被同时指认。

声明未用 = 报告,用了未声明 = FAIL

lin(林)与 hai(孩)有 affinity 声明却不在 state.vars 里。判据写死:声明未用=报告;用了未声明=FAIL。前者只提示,后者直接红——因为「用了未声明」意味着条件引用的变量根本不存在于状态里,是必然的运行时洞。本规格实测「引用未声明好感度变量 0 处」。

P 层:把四条不变量写成断言

P 层把四条不变量写成 5 条断言,数据来自 samplePaths(spec, 2000, seed) 的确定性采样——同种子同结果,所以下面这些数每次跑都一样。

// file: core/test/ne_08_3_invariant.test.ts
// tier: P
// verify: node --test 'core/test/ne_08_3_invariant.test.ts'
// 8.3 好感度的不变量:钳位 / 初值 / 累积口径 / 门限可达性 / 声明未用(四条残差恒为 0)。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

import { samplePaths } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { declaredUnused, usedUndeclaredVars, initOutOfRange, scanPaths, gateReachability } from "../src/inkwell/ne_08_3_invariant.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const spec = JSON.parse(readFileSync(join(HERE, "../../config/story_spec.json"), "utf8")) as Spec;
const SEED = 20261016;
const PATHS = samplePaths(spec, 2000, SEED);

test("① 钳位:采样路径每一步的好感度都是 [0,100] 内整数", () => {
  const r = scanPaths(spec, PATHS);
  assert.ok(r.states > 0, "扫描到的状态数应大于 0");
  assert.equal(r.clampViolations, 0, "不得有越界的好感度");
  console.log(`扫描状态 ${r.states} 个,钳位越界 ${r.clampViolations}`);
});

test("② 初值:全部声明角色的 init 落在自身 affinity.range 内", () => {
  assert.deepEqual(initOutOfRange(spec), []);
});

test("③ 累积口径:新值恒等于 clamp(旧值 + add),无一处不一致", () => {
  const r = scanPaths(spec, PATHS);
  assert.equal(r.cumulativeMismatches, 0);
});

test("④ 阈值门可达性:每条门限都真的可能被跨过(采样最高到达 >= 门限)", () => {
  const probes = gateReachability(spec, PATHS);
  assert.equal(probes.length, 15, `门限应为 15 条,实为 ${probes.length}`);
  const dead = probes.filter((p) => !p.visited || p.maxSeen < p.n);
  assert.deepEqual(dead, [], "不得有结构性不可达的门限");
  console.log(`门限 ${probes.length} 条全部可达;缺口为 0 的 ${probes.filter((p) => p.maxSeen === p.n).map((p) => `${p.site}(${p.v}>=${p.n})`).join(" ")}`);
});

test("声明未用=报告,用了未声明=FAIL:lin/hai 有声明但无引用", () => {
  assert.deepEqual(declaredUnused(spec), ["hai", "lin"]);
  assert.deepEqual(usedUndeclaredVars(spec), [], "不得引用未声明的好感度变量");
});

本机实跑:

扫描状态 52840 个,钳位越界 0
门限 15 条全部可达;缺口为 0 的 d_chapel_2/c_chapel_2c(shen>=55) d_ch_2/c_ch_2c(mu>=55) d_wp_2/c_wp_2c(yan>=25)
✔ ① 钳位:采样路径每一步的好感度都是 [0,100] 内整数 (44.496ms)
✔ ② 初值:全部声明角色的 init 落在自身 affinity.range 内 (0.45375ms)
✔ ③ 累积口径:新值恒等于 clamp(旧值 + add),无一处不一致 (38.917541ms)
✔ ④ 阈值门可达性:每条门限都真的可能被跨过(采样最高到达 >= 门限) (29.115875ms)
✔ 声明未用=报告,用了未声明=FAIL:lin/hai 有声明但无引用 (0.30675ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0

五条全过。最后一条把「声明未用」与「用了未声明」拆成两个断言:前者断言结果恰是 ['hai', 'lin'](报告),后者断言结果为空(FAIL 线)。把报告项写进断言的好处是——它不会因为「反正只是警告」而被悄悄改掉。

自检表

检查项合格标准
钳位残差全部可达状态与每次转移后,4 个变量都在 [0,100] 内,越界 0
初值残差每个声明角色的 init 落在自身 range 内,越出 0
累积口径残差新值恒等于 clamp(旧值 + add),set 不写好感度
门限可达性残差15 条门限无一条结构性不可达;缺口为 0 的三条被点名
声明边界诚实声明未用只报告、用了未声明判 FAIL,两者分开断言

四条不变量定住了,规格到这一步可以放心改:任何一次改 add 的数值或门限值,重跑这三个脚本就能知道有没有踩线。但这些都是跑在命令行上的。下一节把整个规格内联进单个 HTML 文件——不依赖 Node、不依赖 Python,双击就能走完全部分支。

阅读导航:上一节:8.2 阈值门与门限扰动 · 下一节:9.1 规格内联进单文件 HTML 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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