3.1 效果的应用

效果算子只有四个,每个只写一个命名空间:set 写 flag、add 写好感度、grant 与 revoke 写物品。本节把全规格 138 次写入摊平数一遍,校验写入域越界 0,并点出声明了却一次未用的 revoke;P 层用同一批选项断言命名空间互斥,并以把 add 放大到百万级仍被钳回证明钳位确实生效。

3.1 效果的应用

Phaser 选择后果日志 做的是引擎里的后果记录:玩家选了什么、界面显示了什么后果、这条记录怎么挂到 UI 上。本节往下沉一层,落到语言无关的语义:一条选项的 effects 段到底能写什么、写到哪个命名空间、写错了会怎样。日志记的是「发生了什么」,本节定的是「一次写入的合法边界」。

效果的应用是状态推进里唯一真正改写状态的动作。条件只读、goto 只换位置,只有 effects 会把一个值从旧变新。所以「效果能不能被安全地应用」等价于「每一次写入都落在它该落的桶里」。本节把全规格的写入摊平数一遍,再把它写成可断言的测试。

四个算子,四个互斥的写入域

story_spec.json 的效果算子只有四个,每个只写一个命名空间:

算子写入域语义本案例用量
setflag(18 个)置位或清除一个布尔56
add好感度变量(4 个)累加后钳到 [0,100]66
grant物品(9 个)置为持有16
revoke物品(9 个)置为不持有0

四个命名空间两两不重叠——这是这套语法最要紧的一条约束。set 的键必须是已声明的 flag,add 的键必须是已声明的好感度变量,grant/revoke 的键必须是已声明的物品。一旦越界,写入就落进一个没有任何条件会去读的桶里:它不报错,但静默失效。把 add 的键误写成物品名(比如 add: {lantern: 1})就是这样——状态里多出一个没人读的字段,而本该上涨的好感度纹丝不动。

goto 不在这个表里。它是选项的独立字段,只决定下一个节点、不改状态;卷一第 11.1 节已把它与效果算子的分工写死,本节不再重复。

revoke 是「声明未用」的一例

revoke 在条件文法的声明段里存在,但在全规格的 115 条选项里一次都没被用过——16 次物品写入全是 grant。这不是缺陷:声明域大于使用域是规格的常态,本套统一按「声明未用=报告,不判 FAIL」处理。它的价值是提醒读者:算子表里有 revoke,不代表这份故事用到过「收回物品」这条线;如果哪天想加一条「被没收线索」的分支,算子已经就位。

三个命名空间各自的落点

把 138 次写入按命名空间摊开,得到的分布本身就是设计意图的指纹:

  • set 命中 18 个 flag、共 56 次,其中被置位最多的是 saw_lighthouse_light(7 次)、found_yan_journal(5 次)、knows_bell_legend(5 次)、shen_trusts(5 次)。18 个 flag 全部至少被 set 过一次。
  • add 命中 4 个变量、共 66 次,yan(23 次)、shen(18 次)、hui(13 次)、mu(12 次)。四个变量全部落在 state.vars 里。
  • grant 命中 9 个物品、共 16 次,9 个物品全部至少被 grant 过一次;单件最多 2 次(fog_glass / journal / lantern / ledger_page / prayer_bead / rope_knot / salt_letter),bell_key 与 tide_table 各 1 次。

三处「全部覆盖」不是巧合:它说明这份规格里没有一个 flag、变量或物品是纯声明——每个名字都至少被写过一次。真正的「写而不读」是另一回事(写了却没人读),那是 3.3 节差量表要处理的。

S 层:静态清点与写入域校验

下面这段是 S 层脚本 sim/ne_03_1_effects.py。它是纯静态的——不跑路径,只读 config/story_spec.json,把四个算子的用量与落点数成整数,并断言写入域越界为 0。

#!/usr/bin/env python3
# file: sim/ne_03_1_effects.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_03_1_effects.py
"""卷二 3.1 效果的应用(S 层,纯静态,不跑路径)。

只读 config/story_spec.json,把「一个选项的 effects 段能写什么」摊平数一遍:

  算子 → 写入域(互斥,写错命名空间即 FAIL):
    set     → flag(18 个)
    add     → 好感度变量(4 个,写完钳到 [0,100])
    grant   → 物品(9 个)
    revoke  → 物品(9 个)

产出 out/ne_03_1_effects.csv(每个写入键一行)。整数口径:全部 int,禁浮点。
写域越界 0 是本节唯一硬断言:set 的键必须是已声明 flag、add 的键必须是已声明
var、grant/revoke 的键必须是已声明 item。
"""
from __future__ import annotations

import csv
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CSV_PATH = os.path.join(OUT, "ne_03_1_effects.csv")

OPS = ("set", "add", "grant", "revoke")


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


def tally(spec: dict) -> tuple:
    """按算子累计写入次数与命中键,全部整数。"""
    counts = {op: 0 for op in OPS}
    per_key: dict = {op: {} for op in OPS}
    choices = [c for n in spec["dialogue"] for c in (n.get("choices") or [])]
    with_eff = 0
    for c in choices:
        e = c.get("effects") or {}
        if e:
            with_eff += 1
        for op in OPS:
            for k in (e.get(op) or {}):
                counts[op] += 1
                per_key[op][k] = per_key[op].get(k, 0) + 1
    return choices, with_eff, counts, per_key


def main() -> int:
    spec = load_spec()
    flags = set(spec["state"]["flags"])
    vars_ = set(spec["state"]["vars"])
    items = set(spec["state"]["items"])
    choices, with_eff, counts, per_key = tally(spec)

    print(f"spec={spec['meta']['title']}  选项 {len(choices)}  带 effects 的选项 {with_eff}")
    print("算子用量:" + "  ".join(f"{op}={counts[op]}" for op in OPS)
          + f"(合计 {sum(counts.values())})")

    # 写入域:算子 → 允许的命名空间
    domain = {"set": flags, "add": vars_, "grant": items, "revoke": items}
    name = {"set": "flag", "add": "var", "grant": "item", "revoke": "item"}
    print("写入域校验:")
    violations = 0
    for op in OPS:
        hit = set(per_key[op])
        bad = sorted(hit - domain[op])
        violations += len(bad)
        print(f"  {op:<6} → {name[op]:<4} {counts[op]:>3}/{counts[op]:>3} 次落在已声明 {name[op]}"
              f";越界键 {len(bad)} {bad}")
    print(f"写入域越界 {violations}(set 键 ⊆ state.flags;add 键 ⊆ state.vars;grant/revoke 键 ⊆ state.items)")
    if violations:
        print("FAIL 存在写错命名空间的效果")
        return 1

    def fmt(op: str) -> str:
        return "  ".join(f"{k}×{v}" for k, v in sorted(per_key[op].items(), key=lambda kv: (-kv[1], kv[0])))

    print(f"set 命中的 flag({len(per_key['set'])} 个,共 {counts['set']} 次):{fmt('set')}")
    print(f"add 命中的 var({len(per_key['add'])} 个,共 {counts['add']} 次):{fmt('add')}")
    print(f"grant 命中的 item({len(per_key['grant'])} 个,共 {counts['grant']} 次):{fmt('grant')}")
    if counts["revoke"] == 0:
        print("revoke:算子在 conditions 段声明,全 spec 一次未用(声明未用=报告,不判 FAIL)")
    else:
        print(f"revoke 命中的 item:{fmt('revoke')}")

    # 整数不变量:好感度初值 ∈ [0,100]
    bad_init = [c["id"] for c in spec["characters"] if not (0 <= c["affinity"]["init"] <= 100)]
    print(f"I1 全部角色 init ∈ [0,100]:{not bad_init}")
    if bad_init:
        print(f"FAIL {bad_init}")
        return 1

    rows = []
    for op in OPS:
        for k, v in sorted(per_key[op].items()):
            rows.append({"op": op, "key": k, "namespace": name[op], "count": v})

    os.makedirs(OUT, exist_ok=True)
    with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=["op", "key", "namespace", "count"])
        w.writeheader()
        w.writerows(rows)
    print(f"wrote out/ne_03_1_effects.csv rows={len(rows)}")
    print("OK ne_03_1_effects:四个算子写入域互斥,越界 0")
    return 0


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

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

spec=雾港夜航  选项 115  带 effects 的选项 99
算子用量:set=56  add=66  grant=16  revoke=0(合计 138)
写入域校验:
  set    → flag  56/ 56 次落在已声明 flag;越界键 0 []
  add    → var   66/ 66 次落在已声明 var;越界键 0 []
  grant  → item  16/ 16 次落在已声明 item;越界键 0 []
  revoke → item   0/  0 次落在已声明 item;越界键 0 []
写入域越界 0(set 键 ⊆ state.flags;add 键 ⊆ state.vars;grant/revoke 键 ⊆ state.items)
set 命中的 flag(18 个,共 56 次):saw_lighthouse_light×7  found_yan_journal×5  knows_bell_legend×5  shen_trusts×5  hui_trusts×3  knows_tide_rule×3  met_hui×3  met_lin×3  met_mu×3  met_shen×3  mu_trusts×3  promised_hai×3  bell_rung×2  cave_explored×2  dawn_choice_made×2  met_hai×2  has_lantern×1  knows_yan_missing×1
add 命中的 var(4 个,共 66 次):yan×23  shen×18  hui×13  mu×12
grant 命中的 item(9 个,共 16 次):fog_glass×2  journal×2  lantern×2  ledger_page×2  prayer_bead×2  rope_knot×2  salt_letter×2  bell_key×1  tide_table×1
revoke:算子在 conditions 段声明,全 spec 一次未用(声明未用=报告,不判 FAIL)
I1 全部角色 init ∈ [0,100]:True
wrote out/ne_03_1_effects.csv rows=31
OK ne_03_1_effects:四个算子写入域互斥,越界 0

同一趟写出的 out/ne_03_1_effects.csv 每个写入键一行,列依次是 op、key、namespace、count,共 31 行(18 个 flag + 4 个 var + 9 个 item,revoke 无行)。

P 层:把写入域写成测试

静态脚本只说「按声明,每个键都落在合法命名空间」。P 层把它写成两条断言:写入域越界 0,以及 add 写入后确实被钳到 [0,100]。分析逻辑在 core/src/inkwell/ne_03_1_effects.ts 的 tallyEffects,测试在 core/test/ne_03_1_effects.test.ts。

// file: core/test/ne_03_1_effects.test.ts
// tier: P
// verify: node --test 'core/test/ne_03_1_effects.test.ts'
// 3.1 效果的应用:写入域互斥 / 用量清点 / add 的钳位。

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 { initState, applyEffects } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { tallyEffects } from "../src/inkwell/ne_03_1_effects.ts";

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

test("四个算子的用量:set=56 add=66 grant=16 revoke=0,写入域越界 0", () => {
  const t = tallyEffects(spec);
  assert.deepEqual(t.counts, { set: 56, add: 66, grant: 16, revoke: 0 });
  assert.deepEqual(t.violations, []);
});

test("set 只写 flag、add 只写 var、grant/revoke 只写 item", () => {
  const t = tallyEffects(spec);
  const f = new Set(spec.state.flags);
  const v = new Set(spec.state.vars);
  const it = new Set(spec.state.items);
  for (const k of t.keys.set) assert.ok(f.has(k), `set 键 ${k} 不是已声明 flag`);
  for (const k of t.keys.add) assert.ok(v.has(k), `add 键 ${k} 不是已声明 var`);
  for (const k of t.keys.grant) assert.ok(it.has(k), `grant 键 ${k} 不是已声明 item`);
  for (const k of t.keys.revoke) assert.ok(it.has(k), `revoke 键 ${k} 不是已声明 item`);
});

test("set 的 18 个键覆盖全部 18 个 flag;grant 的 9 个键覆盖全部 9 个 item", () => {
  const t = tallyEffects(spec);
  assert.equal(new Set(t.keys.set).size, 18);
  assert.equal(new Set(t.keys.grant).size, 9);
  console.log(`set 命中 flag ${new Set(t.keys.set).size} 个;grant 命中 item ${new Set(t.keys.grant).size} 个`);
});

test("add 写入后夹到 [0,100]:人为放大百万级仍被钳回", () => {
  const s = initState(spec);
  assert.equal(applyEffects({ add: { hui: 1000000 } }, s).aff.hui, 100);
  assert.equal(applyEffects({ add: { hui: -1000000 } }, s).aff.hui, 0);
});

本机实跑 node --test 'core/test/ne_03_1_effects.test.ts':

set 命中 flag 18 个;grant 命中 item 9 个
✔ 四个算子的用量:set=56 add=66 grant=16 revoke=0,写入域越界 0 (1.0405ms)
✔ set 只写 flag、add 只写 var、grant/revoke 只写 item (0.23425ms)
✔ set 的 18 个键覆盖全部 18 个 flag;grant 的 9 个键覆盖全部 9 个 item (0.494417ms)
✔ add 写入后夹到 [0,100]:人为放大百万级仍被钳回 (0.196166ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0

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

自检表

检查项合格标准
写入域互斥set→flag、add→var、grant/revoke→item,越界 0
用量可清点每个算子的次数与命中键都能从规格静态算出
声明未用诚实revoke 声明了却未用,显式点出、不判 FAIL
钳位非冗余有负例证明 add 后确实夹回 [0,100]
算子边界清晰goto 不写进 effects(卷一第 11.1 节已定)

写入域把「一次写入落在哪」定死了。但同一个效果应用两次会怎样?set 重复应用不变,add 会累加——这正好是下一节要分清的确定性与幂等。

阅读导航:上一节:2.3 条件的可满足性 · 下一节:3.2 确定性与幂等 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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