《游戏设计》卷一《玩法机制与可执行规格》 用声明容差断言机制派生量;本章把同一套纪律用到叙事状态上。13.1 与 13.2 证明了三侧「相同」,本节处理更常见的一半:当三侧不完全相同时,哪些差异是偏差、哪些是容差。答案要写成一张可核对的表——而不是一句「差不多就行」。
13.3 偏差与容差
把状态里的量分成两类:必须逐位相同的与允许不同的。前者是判据,后者是纪律——不是「可以不管」,而是「按声明的方式断言」。
| 量类 | 判据 | 必须逐位 | 实测残差 |
|---|---|---|---|
| 整数状态(好感度 / flag / item / 节点 id / 路径) | canonical 串逐位比对 | 是 | 0(500 / 500) |
| 对象键序 | canonical 按键名排序后比对 | 否 | 0(排序后等价) |
| 浮点派生量(示例:调和和) | 声明容差 1e-9 | 否 | 7.532e-13(升序 vs 降序) |
| 文本渲染 | 不进摘要、不比对 | 否 | 不适用(canonical 全 ASCII) |
| 时间戳 | 不进状态、不比对 | 否 | 不适用 |
第一行是硬判据:节点 id、好感度、flag、物品、路径必须逐位相同——13.1 的 500 / 500 就是这一行。下面四行是容差:它们要么被 canonical 的排序规则消解(键序),要么根本不进摘要(文本、时间戳),要么只能用浮点算、必须声明一个可接受的误差上界(浮点派生量)。唯一不能做的是把浮点量也当成逐位判据——下面那条负例就展示了「差一个整数」会怎样。
S 层:故意引入偏差的负例
负例只改一个数:把好感度钳位上限从规格值 100 改成 99。其余一字不动。脚本重放 500 条路径,断言摘要必然改变,并报出首个分歧点。
#!/usr/bin/env python3
# file: sim/ne_13_3_tolerance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_13_3_tolerance.py
"""卷二 13.3 偏差与容差(S 层)。
把「什么必须逐位相同、什么允许不同」落成一张容差表,并给一个故意引入偏差的负例:
把好感度钳位上限从 100 改成 99,断言摘要必然改变,并报出首个分歧点(路径 / 步 / 选项 / 值)。
容差表里「浮点派生量」一栏给一个真跑残差:同一批值的调和和按升序 / 降序累加,
双精度结果在末几位不同——这就是「浮点派生量按声明容差断言、不逐位」的实证。
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ne_11_common import OUT, canonical, cond_ok, fnv1a, init_state, load_spec # noqa: E402
JSON_PATH = os.path.join(OUT, "ne_13_3_tolerance.json")
SEED, N, HI_OK, HI_BAD = 20261016, 500, 100, 99
def node(spec: dict, nid: str) -> dict:
return next(n for n in spec["dialogue"] if n["id"] == nid)
def apply(spec: dict, s: dict, c: dict, hi: int) -> dict:
"""唯一的偏差来源:好感度钳位上限 hi。hi=100 是规格值,hi=99 是负例扰动。"""
eff = c.get("effects") or {}
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] = min(hi, max(0, 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": c["goto"], "aff": aff, "flags": flags, "items": items,
"path": s["path"] + [c["goto"]]}
def choice_of(spec: dict, s: dict, cid: str) -> dict:
return next(x for x in (node(spec, s["node"]).get("choices") or []) if x["id"] == cid)
def sample_paths(spec: dict, n: int, seed: int) -> list[list[str]]:
rng, out = seed, []
for _ in range(n):
s, trail = init_state(spec), []
for _step in range(200):
cur = node(spec, 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)]
trail.append(c["id"])
s = apply(spec, s, c, HI_OK)
out.append(trail)
return out
def replay(spec: dict, path: list[str], hi: int) -> dict:
s = init_state(spec)
for cid in path:
s = apply(spec, s, choice_of(spec, s, cid), hi)
return s
def first_divergence(spec: dict, paths: list[list[str]]) -> dict | None:
for i, p in enumerate(paths):
a, b = init_state(spec), init_state(spec)
for j, cid in enumerate(p):
a = apply(spec, a, choice_of(spec, a, cid), HI_OK)
b = apply(spec, b, choice_of(spec, b, cid), HI_BAD)
if canonical(a) != canonical(b):
field = next(k for k in sorted(a["aff"]) if a["aff"][k] != b["aff"][k])
return {"path": i, "step": j, "choice": cid, "field": field,
"hi100": a["aff"][field], "hi99": b["aff"][field],
"digest100": fnv1a(canonical(a).encode()), "digest99": fnv1a(canonical(b).encode())}
return None
def main() -> int:
spec = load_spec()
paths = sample_paths(spec, N, SEED)
diverged = sum(1 for p in paths
if fnv1a(canonical(replay(spec, p, HI_OK)).encode())
!= fnv1a(canonical(replay(spec, p, HI_BAD)).encode()))
first = first_divergence(spec, paths)
vals = [v for p in paths for v in replay(spec, p, HI_OK)["aff"].values()]
asc = desc = 0.0
for v in sorted(vals):
asc += 1.0 / (v + 1)
for v in reversed(sorted(vals)):
desc += 1.0 / (v + 1)
float_residual = abs(asc - desc)
table = [
{"class": "整数状态(好感度 / flag / item / 节点 id / 路径)", "criterion": "canonical 串逐位比对",
"bitwise": True, "residual": 0},
{"class": "对象键序", "criterion": "canonical 按键名排序后比对", "bitwise": False, "residual": 0},
{"class": "浮点派生量(示例:调和和)", "criterion": "声明容差 1e-9", "bitwise": False,
"residual": float_residual},
{"class": "文本渲染", "criterion": "不进摘要、不比对", "bitwise": False, "residual": None},
{"class": "时间戳", "criterion": "不进状态、不比对", "bitwise": False, "residual": None},
]
print(f"spec={spec['meta']['title']} 采样 n={N} seed={SEED}")
print(f"负例 · 钳位上限 {HI_OK} → {HI_BAD}:{N} 条路径里 {diverged} 条终局摘要改变(其余落在未触顶路径上)")
if first:
print(f"首个分歧点:路径 #{first['path']} 第 {first['step']} 步 选项 {first['choice']} "
f"{first['field']}={first['hi100']}(上限 100) vs {first['field']}={first['hi99']}(上限 99)")
print(f" 该步摘要 {first['digest100']} → {first['digest99']}(必须不同)")
print(f"容差实证 · 浮点派生量(调和和,升序 vs 降序):{asc!r} vs {desc!r} 残差 {float_residual:.3e}")
print("容差表:")
for t in table:
r = "不适用" if t["residual"] is None else f"{t['residual']:.3e}" if isinstance(t["residual"], float) else str(t["residual"])
print(f" {t['class']:<32} 必须逐位={'是' if t['bitwise'] else '否':<3} 残差 {r}")
assert diverged > 0 and first is not None, "负例未造成任何偏差"
assert float_residual > 0, "浮点残差应为非零"
os.makedirs(OUT, exist_ok=True)
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump({"n": N, "seed": SEED, "hi_ok": HI_OK, "hi_bad": HI_BAD,
"diverged_paths": diverged, "first_divergence": first,
"float_residual": float_residual, "tolerance": table}, fh,
ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/ne_13_3_tolerance.json")
print("OK ne_13_3_tolerance:负例造成偏差,首个分歧点已定位,容差表已落盘")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_13_3_tolerance.py:
spec=雾港夜航 采样 n=500 seed=20261016
负例 · 钳位上限 100 → 99:500 条路径里 12 条终局摘要改变(其余落在未触顶路径上)
首个分歧点:路径 #13 第 20 步 选项 c_fg_1a shen=100(上限 100) vs shen=99(上限 99)
该步摘要 2034325278 → 3825501945(必须不同)
容差实证 · 浮点派生量(调和和,升序 vs 降序):46.86073654770573 vs 46.860736547704974 残差 7.532e-13
容差表:
整数状态(好感度 / flag / item / 节点 id / 路径) 必须逐位=是 残差 0
对象键序 必须逐位=否 残差 0
浮点派生量(示例:调和和) 必须逐位=否 残差 7.532e-13
文本渲染 必须逐位=否 残差 不适用
时间戳 必须逐位=否 残差 不适用
wrote out/ne_13_3_tolerance.json
OK ne_13_3_tolerance:负例造成偏差,首个分歧点已定位,容差表已落盘
P 层:TS 侧重放负例并定位同一分歧点
门禁在 TS 侧用同样的钳位扰动重放 500 条路径:正向对照(100 对 100)必须零分歧,负例(100 对 99)必须命中同一批 12 条路径、同一个首个分歧点。
// file: core/test/ne_13_3_tolerance.test.ts
// tier: P
// verify: node --test 'core/test/ne_13_3_tolerance.test.ts'
// 卷二 13.3:故意引入偏差(钳位上限 99),断言摘要必须改变,并定位首个分歧点;
// 正向对照(hi=100 对 hi=100)必须零分歧。只对整数状态写逐位一致。
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 { digest, samplePaths } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { firstDivergence, replayClamped } from "../src/inkwell/ne_13_3_tolerance.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;
const paths = samplePaths(spec, 500, 20261016);
const py = JSON.parse(readFileSync(join(ROOT, "out/ne_13_3_tolerance.json"), "utf8")) as {
diverged_paths: number;
first_divergence: { path: number; step: number; choice: string; field: string; hi100: number; hi99: number };
float_residual: number;
};
test("负例 · 钳位上限 99:12 / 500 条路径的终局摘要改变", () => {
let diverged = 0;
for (const p of paths) {
if (digest(replayClamped(spec, p, 100)) !== digest(replayClamped(spec, p, 99))) diverged += 1;
}
assert.equal(diverged, 12);
assert.equal(diverged, py.diverged_paths);
console.log(`钳位 100 → 99:${diverged} / ${paths.length} 条摘要改变`);
});
test("正向对照 · 钳位 100 对 100:零分歧(残差恒为 0)", () => {
let diverged = 0;
for (const p of paths) {
if (digest(replayClamped(spec, p, 100)) !== digest(replayClamped(spec, p, 100))) diverged += 1;
}
assert.equal(diverged, 0);
});
test("首个分歧点:路径 #13 第 20 步 c_fg_1a,shen 100 vs 99,摘要必须不同", () => {
const d = firstDivergence(spec, paths, 99);
assert.ok(d);
assert.equal(d.path, 13);
assert.equal(d.step, 20);
assert.equal(d.choice, "c_fg_1a");
assert.equal(d.field, "shen");
assert.equal(d.hi100, 100);
assert.equal(d.hiBad, 99);
assert.notEqual(d.digest100, d.digestBad);
assert.equal(d.path, py.first_divergence.path);
assert.equal(d.step, py.first_divergence.step);
assert.equal(d.choice, py.first_divergence.choice);
console.log(`首个分歧点 #${d.path}/${d.step} ${d.choice} ${d.field} ${d.hi100}→${d.hiBad}:摘要 ${d.digest100} → ${d.digestBad}`);
});
test("容差表 · 浮点派生量按声明容差断言(残差 7.5e-13 < 1e-9)", () => {
assert.ok(py.float_residual > 0);
assert.ok(py.float_residual < 1e-9);
console.log(`浮点派生量残差 ${py.float_residual.toExponential(3)} < 声明容差 1e-9`);
});
本机实跑 node --test 'core/test/ne_13_3_tolerance.test.ts':
钳位 100 → 99:12 / 500 条摘要改变
首个分歧点 #13/20 c_fg_1a shen 100→99:摘要 2034325278 → 3825501945
浮点派生量残差 7.532e-13 < 声明容差 1e-9
✔ 负例 · 钳位上限 99:12 / 500 条路径的终局摘要改变 (24.161958ms)
✔ 正向对照 · 钳位 100 对 100:零分歧(残差恒为 0) (14.452125ms)
✔ 首个分歧点:路径 #13 第 20 步 c_fg_1a,shen 100 vs 99,摘要必须不同 (2.251041ms)
✔ 容差表 · 浮点派生量按声明容差断言(残差 7.5e-13 < 1e-9) (0.09875ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
判读:偏差有大小,容差有上界
「12 / 500 条摘要改变」:一个整数(钳位上限 100 → 99)就让 12 条路径的终局摘要变了。其余 488 条没变,是因为那些路径的好感度从未触顶——偏差只在触顶的地方显形。这说明摘要不是「一改就全变」的敏感噪声,而是精确地指出哪些路径受影响。
「首个分歧点 #13 第 20 步 c_fg_1a,shen 100 vs 99」:第 13 条路径走到第 20 步时,shen 被钳到 100;改成 99 后,这一步的 canonical 串第一次不同,摘要从 2034325278 变成 3825501945。首个分歧点给出了偏差的坐标——哪条路径、哪一步、哪个字段、差多少。
「正向对照零分歧」:把两侧都设成 100,0 条路径有差异。这条对照排除了「摘要本来就是随机噪声」的可能——差异只来自那个被改动的整数。
「浮点残差 7.532e-13 < 1e-9」:同一批好感度值的调和和,升序累加与降序累加在双精度下末几位不同。这不是缺陷,是浮点的本性;容差表因此不把浮点量列为逐位判据,而是声明一个上界(1e-9),实测残差 7.532e-13 落在界内。
| 观察 | 数字 | 读法 |
|---|---|---|
| 负例受影响的路径 | 12 / 500 | 偏差只在触顶处显形 |
| 正向对照分歧 | 0 / 500 | 差异只来自被改动的整数 |
| 首个分歧点 | #13 / 第 20 步 / shen | 偏差有精确坐标 |
| 浮点派生量残差 | 7.532e-13 | 落在声明容差 1e-9 内 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 两类量分明 | 整数状态逐位;浮点 / 键序 / 文本 / 时间戳按声明方式 |
| 负例必给 | 钳位 100 → 99,摘要必须改变 |
| 分歧点可定位 | 报出路径 / 步 / 选项 / 字段 / 值 |
| 正向对照 | 100 对 100 零分歧 |
| 容差有上界 | 浮点派生量声明 1e-9,实测落在界内 |
| 口径纪律 | 只对整数状态写「逐位一致」 |
第 13 章到此收束:摘要立住了(13.1)、三宿主对齐了(13.2)、偏差与容差的边界写死了(13.3)。下一章把这条一致性链变成可回归的门禁——属性测试、随机路径模糊测试与告警阈值。
阅读导航:上一节:13.2 同源不同宿主 · 下一节:14.1 属性测试与不变量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。