3.1 效果的应用
Phaser 选择后果日志
做的是引擎里的后果记录:玩家选了什么、界面显示了什么后果、这条记录怎么挂到 UI 上。本节往下沉一层,落到语言无关的语义:一条选项的 effects 段到底能写什么、写到哪个命名空间、写错了会怎样。日志记的是「发生了什么」,本节定的是「一次写入的合法边界」。
效果的应用是状态推进里唯一真正改写状态的动作。条件只读、goto 只换位置,只有 effects 会把一个值从旧变新。所以「效果能不能被安全地应用」等价于「每一次写入都落在它该落的桶里」。本节把全规格的写入摊平数一遍,再把它写成可断言的测试。
四个算子,四个互斥的写入域
story_spec.json 的效果算子只有四个,每个只写一个命名空间:
| 算子 | 写入域 | 语义 | 本案例用量 |
|---|---|---|---|
set | flag(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 确定性与幂等 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。