10.2 阈值门

阈值门就是条件里的 ['aff', 名, '>=', 整数]:它可能长在选项的 when、节点的 line_when 或结局的 when 上。S 层把全规格 15 条门限抽成一张表,在 20000 条采样路径上给出每条门限的跨越率、最高到达与缺口,基线 0 条死门;P 层断言门限未达时选项不可选、恰好达到时可选的边界行为。

10.2 阈值门

Godot 对话选择历史与后果日志 记的是玩家已经做过的选择;本节管的是玩家还不能做的选择。当某个选项要求「好感度 ≥ 25」时,那 25 就是一个阈值门——它把一条对话边锁上,直到状态满足为止。日志是事后的,门限是事前的,两者合起来才解释得清「选择为什么可选」。

阈值门的形状是固定的:["aff", 名字, ">=", 整数]。本节做两件事:把全规格的阈值门抽成一张门限表(每条门限在采样路径上被判定几次、满足几次,即跨越率),并断言「门限未达时选项不可选」。

门限长在三处

阈值门不只长在选项上。全规格扫下来是 15 条,分三类:

位置字段条数判定时机不满足时的行为
选项choices[].when13玩家面对该节点时该选项不出现在可选列表里
节点dialogue[].line_when1要渲染这一行时该行整段不渲染
结局endings[].when1路径走到终点时该结局不可达

三处的判定时机不同,所以同一条 ["aff", "yan", ">=", 25] 长在不同位置,语义完全不一样。

门限表:跨越率、最高到达与缺口

门限的判定次数不是设计出来的,是采样跑出来的。脚本用 MINSTD(同种子)跑 20000 条路径,每条门限记两个数:visits(被判定几次)与 sat(满足几次),跨越率取千分位整数 sat*1000//visits。跑出来的门限值集合是 [25, 30, 40, 55, 60, 70]——六个档位,全部落在 [0,100] 内;跨越率从 54‰ 到 1000‰,基线死门 0 条。

表里还有一列「缺口」,是 门限 - 该门限被判定时变量的最高到达值。d_wp_2/c_wp_2c 的缺口是 0——20000 条采样里这条门限上的变量最多只到过 25,正好压在线上。它是最脆弱的一条:任何一个让 yan 少加 1 的改动都会把它变成死门。缺口为 0 的门限应当被单独盯住,它们是阈值设计里最先失效的地方。

判定次数本身也是一条信息:d_dq_2/c_dq_2a 判定了 20000 次(等于路径总数,因为它是结局门限,每条路径走到终点都判一次),而 e_lit/e_lit 只判了 1018 次(只有走到这个结局的路径才会遇到它)。它告诉你这条门限挡在路的哪一段。

跨越率是采样口径的代理指标,只用于相对比较:比较两条门限谁更常被满足、比较扰动前后同一条门限的变化。它不是「这条门限设计得好不好」的绝对判断——采样策略换一种,数字就会变。

S 层:门限表脚本

下面这段是 S 层脚本 sim/na_10_2_threshold_gate.py。它把三处的 aff 条件(含嵌在 and/or/not 里的)抽平,跑采样统计,断言 I2(门限阈值全部在 [0,100] 内)。它同时是 10.3 复用的门限机件(collect_gates / run_gates)。

#!/usr/bin/env python3
# file: sim/na_10_2_threshold_gate.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_10_2_threshold_gate.py
"""卷一 10.2 阈值门(S 层):把 ["aff", 名, ">=", 整数] 抽成一张门限表。

阈值门可能长在三处:选项的 when、对话节点的 line_when、结局的 when。本节把全部门限抽出来,
在 20000 条采样路径上统计「判定过几次、其中满足几次」,每条门限拿到一个**跨越率**;跨越率
为 0 即**死门**。跨越率是采样口径的**代理指标**,只用于相对比较。整数口径:千分位整数
(sat*1000//visits),禁浮点。产出 out/na_10_2_threshold_gate.csv。
本模块同时是 10.3 B 层复用的门限机件(collect_gates / run_gates)。
"""
from __future__ import annotations

import json
import os
import sys

AFF_LO, AFF_HI = 0, 100
SAMPLE_N = 20000
SEED = 20261016
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")


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 cond_ok(c, s: dict) -> bool:
    if c is None:
        return True
    k = c[0]
    if k == "flag":
        return s["flags"].get(c[1], 0) == c[2]
    if k == "item":
        return s["items"].get(c[1], 0) == c[2]
    if k == "aff":
        v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
        return (v >= n) if op == ">=" else (v < n) if op == "<" else (v == n) if op == "==" else (v != n)
    if k == "and":
        return cond_ok(c[1], s) and cond_ok(c[2], s)
    if k == "or":
        return cond_ok(c[1], s) or cond_ok(c[2], s)
    return not cond_ok(c[1], s)


def walk_aff(c):
    """抽出条件里全部 aff 合取项(含嵌套)。"""
    if not c:
        return
    if c[0] == "aff":
        yield c
    elif c[0] == "not":
        yield from walk_aff(c[1])
    elif c[0] in ("and", "or"):
        yield from walk_aff(c[1])
        yield from walk_aff(c[2])


def init_state(spec: dict, delta: int = 0) -> dict:
    """初始值来自 characters[].affinity.init;delta 是整体扰动(10.3 B 层用)。"""
    aff = {v: clamp(next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v) + delta)
           for v in sorted(spec["state"]["vars"])}
    flags = {f: 0 for f in spec["state"]["flags"]}
    items = {i: 0 for i in spec["state"]["items"]}
    return {"node": spec["meta"]["start"], "aff": aff, "flags": flags, "items": items}


def apply_effects(eff: dict, s: dict) -> dict:
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k in sorted(eff.get("set") or {}):
        flags[k] = eff["set"][k]
    for k in sorted(eff.get("add") or {}):
        aff[k] = clamp(aff.get(k, 0) + eff["add"][k])
    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 {"node": s["node"], "aff": aff, "flags": flags, "items": items}


def collect_gates(spec: dict) -> tuple:
    """门限表 + 每个节点上要在访问时判定的门限下标。"""
    gates: list = []
    at: dict = {}

    def add(kind: str, node: str, choice: str, cond) -> None:
        for sub in walk_aff(cond):
            gates.append({"kind": kind, "node": node, "choice": choice, "cond": sub,
                          "var": sub[1], "op": sub[2], "n": sub[3], "visits": 0, "sat": 0, "max_seen": AFF_LO})
            at.setdefault(node, []).append(len(gates) - 1)

    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"))
    name2node = {n["ending"]: n["id"] for n in spec["dialogue"] if n.get("ending")}
    for e in spec["endings"]:
        add("ending", name2node.get(e["name"], "-"), e["id"], e.get("when"))
    return gates, at


def run_gates(spec: dict, gates: list, at: dict, n: int, seed: int, delta: int = 0) -> dict:
    """有界随机采样,统计每条门限的判定 / 满足次数。MINSTD 同种子。"""
    by_id = {x["id"]: x for x in spec["dialogue"]}
    for g in gates:
        g["visits"] = g["sat"] = 0
        g["max_seen"] = AFF_LO
    rng = seed
    for _ in range(n):
        s = init_state(spec, delta)
        for _step in range(200):
            for gid in at.get(s["node"], ()):
                g = gates[gid]
                g["visits"] += 1
                if s["aff"].get(g["var"], 0) > g["max_seen"]:
                    g["max_seen"] = s["aff"][g["var"]]
                if cond_ok(g["cond"], s):
                    g["sat"] += 1
            cur = by_id[s["node"]]
            if cur.get("ending"):
                break
            opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
            if not opts:
                break
            rng = (rng * 48271) % 2147483647
            c = opts[rng % len(opts)]
            s = apply_effects(c.get("effects") or {}, s)
            s["node"] = c["goto"]
    for g in gates:
        g["rate_permille"] = g["sat"] * 1000 // max(1, g["visits"])
        g["gap"] = g["n"] - g["max_seen"]
        g["dead"] = 1 if g["sat"] == 0 else 0
    tv = sum(g["visits"] for g in gates)
    ts = sum(g["sat"] for g in gates)
    return {"visits": tv, "satisfied": ts, "rate_permille": ts * 1000 // max(1, tv),
            "dead": sum(g["dead"] for g in gates)}


def main() -> int:
    spec = load_spec()
    gates, at = collect_gates(spec)
    agg = run_gates(spec, gates, at, SAMPLE_N, SEED)

    kinds: dict = {}
    for g in gates:
        kinds[g["kind"]] = kinds.get(g["kind"], 0) + 1
    print(f"spec={spec['meta']['title']}  门限 {len(gates)} 条({kinds})  采样 {SAMPLE_N} 条路径 seed={SEED}")
    print(f"门限变量:{sorted({g['var'] for g in gates})}  门限值:{sorted({g['n'] for g in gates})}")

    print("门限表(按跨越率升序):")
    print("  门限                         判定次数   满足次数  跨越率     最高到达  缺口")
    for g in sorted(gates, key=lambda x: (x["rate_permille"], x["node"], x["choice"])):
        site = f"{g['node']}/{g['choice']}" if g["choice"] != "-" else f"{g['node']}[{g['kind']}]"
        print(f"  {site:<26} {g['visits']:>8} {g['sat']:>9}  {g['rate_permille']:>4}‰  "
              f"{g['max_seen']:>6}   {g['gap']:>3}")

    dead = [g["node"] + "/" + g["choice"] for g in gates if g["dead"]]
    print(f"死门(跨越率 0):{len(dead)} 条" + (f"  {dead}" if dead else "")
          + f"   全体门限跨越率 {agg['rate_permille']}‰({agg['satisfied']}/{agg['visits']})")

    bad = [g for g in gates if not (AFF_LO <= g["n"] <= AFF_HI)]
    print(f"I2 门限阈值全部 ∈ [0,100]:{not bad}")
    if bad:
        return 1

    print("OK na_10_2_threshold_gate:门限表已出,跨越率与死门均已统计,I2 成立")
    return 0


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

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

spec=雾港夜航  门限 15 条({'choice': 13, 'line_when': 1, 'ending': 1})  采样 20000 条路径 seed=20261016
门限变量:['hui', 'mu', 'shen', 'yan']  门限值:[25, 30, 40, 55, 60, 70]
门限表(按跨越率升序):
  门限                         判定次数   满足次数  跨越率     最高到达  缺口
  d_tc_2/c_tc_2c                 4985       273    54‰      45    -5
  d_ho_3/c_ho_3b                12499      1419   113‰     100   -30
  d_lb_2/c_lb_2c                10050      1142   113‰      35    -5
  d_bw_2/c_bw_2c                14604      2038   139‰      82   -22
  d_wp_3/c_wp_3b                 9942      2235   224‰      35    -5
  d_tavern_3/c_tavern_3b        10038      2779   276‰      67    -7
  d_wp_2/c_wp_2c                 9942      3326   334‰      25     0
  d_db_2/c_db_2c                 7501      2943   392‰      85   -25
  d_chapel_2/c_chapel_2c         9962      4151   416‰      55     0
  d_ch_2/c_ch_2c                10058      4222   419‰      55     0
  d_dq_2/c_dq_2a                20000      8398   419‰     100   -60
  d_tavern_2/c_tavern_2c        10038      4214   419‰      57    -2
  d_lt_2[line_when]             10011      5868   586‰      75   -45
  d_chapel_3/c_chapel_3b         9962      6233   625‰      65    -5
  e_lit/e_lit                    1018      1018  1000‰     100   -60
死门(跨越率 0):0 条   全体门限跨越率 333‰(50259/150610)
I2 门限阈值全部 ∈ [0,100]:True
OK na_10_2_threshold_gate:门限表已出,跨越率与死门均已统计,I2 成立

P 层:门限未达即不可选

S 层给的是频率,P 层给的是判定。core/test/na_10_2_threshold_gate.test.ts 断言三件事:门限未达时 applyChoice 抛 unavailable;恰好达到时(>= 是闭区间)可选;穷举全部顶层门限,n-1 拒绝、n 通过。

// file: core/test/na_10_2_threshold_gate.test.ts
// tier: P
// verify: node --test 'core/test/na_10_2_threshold_gate.test.ts'
// 10.2 阈值门:门限未达时选项不可选;门限恰好达到时可选的边界行为。

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 { condOk, applyChoice } from "../src/inkwell/engine.ts";
import type { Spec, State, Cond } from "../src/inkwell/engine.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const readJson = (p: string) => JSON.parse(readFileSync(p, "utf8"));

const spec = readJson(join(ROOT, "config/story_spec.json")) as Spec;

function stateAt(node: string, aff: Record<string, number>): State {
  const full: Record<string, number> = {};
  for (const v of spec.state.vars) full[v] = aff[v] ?? 0;
  const flags: Record<string, number> = {};
  for (const f of spec.state.flags) flags[f] = 0;
  const items: Record<string, number> = {};
  for (const i of spec.state.items) items[i] = 0;
  return { node, aff: full, flags, items, path: [node] };
}

/** 「顶层就是 ["aff", 名, ">=", 整数]」的选项门限。 */
function topLevelGates(): { node: string; choice: string; v: string; n: number }[] {
  const out: { node: string; choice: string; v: string; n: number }[] = [];
  for (const node of spec.dialogue) {
    for (const c of node.choices ?? []) {
      const w = c.when as Cond | null;
      if (w && w[0] === "aff" && w[2] === ">=") {
        out.push({ node: node.id, choice: c.id, v: w[1] as string, n: w[3] as number });
      }
    }
  }
  return out;
}

test("门限未达时选项不可选(点名两处真实门限)", () => {
  const low = stateAt("d_wp_2", { yan: 24 });
  assert.equal(condOk(["aff", "yan", ">=", 25], low), false);
  assert.throws(() => applyChoice(spec, low, "c_wp_2c"), /unavailable/);

  const low2 = stateAt("d_tavern_2", { hui: 54 });
  assert.equal(condOk(["aff", "hui", ">=", 55], low2), false);
  assert.throws(() => applyChoice(spec, low2, "c_tavern_2c"), /unavailable/);
});

test("门限恰好达到时可选(边界闭区间)", () => {
  const at = stateAt("d_wp_2", { yan: 25 });
  assert.equal(condOk(["aff", "yan", ">=", 25], at), true);
  assert.equal(applyChoice(spec, at, "c_wp_2c").node, "d_wp_3");
});

test("穷举全部顶层门限:n-1 拒绝、n 通过,且阈值都是 [0,100] 内整数", () => {
  const gates = topLevelGates();
  // 13 条选项门限里,12 条的 when 顶层就是 aff;d_dq_2/c_dq_2a 的 aff 嵌在 and 里(由 S 层覆盖)。
  assert.equal(gates.length, 12, `顶层门限应为 12 条,实为 ${gates.length}`);
  for (const g of gates) {
    assert.ok(Number.isInteger(g.n) && g.n >= 0 && g.n <= 100, `${g.v} 的门限 ${g.n} 越界`);
    const below = stateAt(g.node, { [g.v]: g.n - 1 });
    const ok = stateAt(g.node, { [g.v]: g.n });
    assert.equal(condOk(["aff", g.v, ">=", g.n], below), false, `${g.node}/${g.choice} 在 ${g.n - 1} 时不应可选`);
    assert.equal(condOk(["aff", g.v, ">=", g.n], ok), true, `${g.node}/${g.choice} 在 ${g.n} 时应可选`);
    assert.throws(() => applyChoice(spec, below, g.choice), /unavailable/);
    assert.doesNotThrow(() => applyChoice(spec, ok, g.choice));
  }
  console.log(`顶层门限 ${gates.length} 条:${gates.map((g) => `${g.v}>=${g.n}`).join(" ")}`);
});

本机实跑 node --test 'core/test/na_10_2_threshold_gate.test.ts'(摘录):

顶层门限 12 条:hui>=55 hui>=60 shen>=55 shen>=60 mu>=55 yan>=25 yan>=30 yan>=30 yan>=40 mu>=70 shen>=60 hui>=60
✔ 门限未达时选项不可选(点名两处真实门限) (0.802166ms)
✔ 门限恰好达到时可选(边界闭区间) (0.168ms)
✔ 穷举全部顶层门限:n-1 拒绝、n 通过,且阈值都是 [0,100] 内整数 (0.858042ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0

穷举测试只找到 12 条顶层门限,而 S 层说有 13 条选项门限。差的这一条是 d_dq_2/c_dq_2a:它的 aff 条件嵌在一个 and 里,不是 when 的顶层。这不是漏掉,而是两种口径的分工——P 层穷举「顶层就是 aff」的门限(能直接构造单变量状态来测边界),嵌套形态的门限由 S 层抽平后覆盖。测试里把这件事写成注释和断言,就是为了让「12」这个数字可解释。

自检表

检查项合格标准
三处都扫选项 when、节点 line_when、结局 when 一处不漏
嵌套抽平and/or/not 里的 aff 也要抽出来,不能只认顶层
阈值合法全部门限值都在 [0,100] 内(I2)
死门显式跨越率 0 的门限必须被单独列出,不能混在表里
口径诚实跨越率声明为代理指标,只做相对比较

门限表说的是「一条门限被跨过多少次」。但它没说另一件更要紧的事:好感度在一条路径上是怎么走过去的——是稳步抬升,还是来回震荡?下一节把每个变量的好感度沿路径画成曲线,把「形状」也变成一个可分类的量。

阅读导航:上一节:10.1 好感度的初始化与增减 · 下一节:10.3 好感度曲线的形状 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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