Godot 的选择历史日志 记录的是一个玩家的一次选择史——一局、一条轨迹、可回看。本节要的是一批:用固定种子采样 20 条不同的选择序列,逐条注入页面,逐条断言终局与 Python 侧一致。一条路径通了只证明这一条;一批路径一起通,才证明这台引擎在整片状态空间上可复现。
10.3 多路径批跑
单条路径的终局断言(10.2)回答「这条路对不对」;批跑回答「很多条路是不是都对」。采样用 Lehmer MINSTD 可播种 PRNG——与内核 engine.ts 的 samplePaths 同算法同种子,所以 Python 侧采出的 20 条序列与 TS 侧采出的 20 条逐条相同。
S 层:采样 20 条路径
sim/ne_10_3_batch_paths.py 用固定种子 20261016 采样 20 条路径,每条记下选择序列、终局节点、结局与状态摘要,落成 out/ne_10_3_batch_paths.json。这份 JSON 是批跑的期望值:W 层拿它注入对照,P 层拿它回放对照。
#!/usr/bin/env python3
# file: sim/ne_10_3_batch_paths.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_10_3_batch_paths.py
"""卷二 10.3 多路径批跑(S 层):用 Lehmer MINSTD 采样 20 条路径,落成对照表。
采样算法与 core/src/inkwell/engine.ts 的 samplePaths 逐行同构(同算法同种子):
每步在当前节点的可用选项里按 PRNG 取一条,走出 20 条不同的选择序列,各记下
终局节点、结局与 FNV-1a 状态摘要。产物 out/ne_10_3_batch_paths.json 同时供
W 层 web/verify_ne10.mjs 注入对照与 P 层 core/test/ne_10_3_batch.test.ts 回放对照。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SPEC = os.path.join(ROOT, "config/story_spec.json")
OUT = os.path.join(ROOT, "out/ne_10_3_batch_paths.json")
N, SEED, MAX_STEPS = 20, 20261016, 200
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, "<": v < n, "==": v == n, "!=": v != n}[op]
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 node(spec: dict, nid: str) -> dict:
return next(n for n in spec["dialogue"] if n["id"] == nid)
def init_state(spec: dict) -> dict:
aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in sorted(spec["state"]["vars"])}
return {"node": spec["meta"]["start"], "aff": aff,
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])},
"path": [spec["meta"]["start"]]}
def step(s: dict, c: dict) -> dict:
eff = c.get("effects") or {}
aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
for k, v in (eff.get("set") or {}).items():
flags[k] = v
for k, v in (eff.get("add") or {}).items():
aff[k] = min(100, max(0, aff.get(k, 0) + v))
for k, v in (eff.get("grant") or {}).items():
items[k] = v
for k in (eff.get("revoke") or {}):
items[k] = 0
return {"node": c["goto"], "aff": aff, "flags": flags, "items": items,
"path": s["path"] + [c["goto"]]}
def canonical(s: dict) -> str:
aff = ",".join(f"{k}={s['aff'][k]}" for k in sorted(s["aff"]))
fl = ",".join(k for k in sorted(s["flags"]) if s["flags"][k])
it = ",".join(k for k in sorted(s["items"]) if s["items"][k])
return f"{s['node']}|{aff}|{fl}|{it}|{'>'.join(s['path'])}"
def digest(s: dict) -> int:
h = 2166136261
for ch in canonical(s):
h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
return h
def main() -> int:
with open(SPEC, encoding="utf-8") as fh:
spec = json.load(fh)
rng = SEED
def rnd(mod: int) -> int:
nonlocal rng
rng = (rng * 48271) % 2147483647
return rng % mod
paths: list[dict] = []
for i in range(N):
s, seq = init_state(spec), []
for _ in range(MAX_STEPS):
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
c = opts[rnd(len(opts))]
seq.append(c["id"])
s = step(s, c)
paths.append({"name": f"p{i + 1:02d}", "seq": seq, "node": s["node"],
"ending": node(spec, s["node"]).get("ending", ""),
"digest": digest(s), "canonical": canonical(s)})
endings: dict[str, int] = {}
for p in paths:
endings[p["ending"]] = endings.get(p["ending"], 0) + 1
print(f"采样路径 {len(paths)} 条(seed={SEED})")
print(f"步数区间 {min(len(p['seq']) for p in paths)}–{max(len(p['seq']) for p in paths)}")
print(f"结局分布 {json.dumps(endings, ensure_ascii=False, sort_keys=True)}")
for p in paths:
print(f" {p['name']} 步 {len(p['seq']):>2} 终局 {p['node']:<7} 结局 {p['ending']:<4} 摘要 {p['digest']}")
doc = {"seed": SEED, "n": N, "max_steps": MAX_STEPS,
"endings": endings, "paths": paths}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as fh:
json.dump(doc, fh, ensure_ascii=False, indent=2)
fh.write("\n")
if len({p["digest"] for p in paths}) == 1:
print("FAIL 20 条路径摘要全同,采样退化")
return 1
print("OK ne_10_3_batch_paths:20 条路径采样完成,摘要去重后非退化")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑:
采样路径 20 条(seed=20261016)
步数区间 22–28
结局分布 {"沉没": 4, "灯守": 1, "离港": 13, "缄默": 2}
p01 步 22 终局 e_dark 结局 离港 摘要 3976226298
p02 步 25 终局 e_dark 结局 离港 摘要 3848604931
p03 步 28 终局 e_dark 结局 离港 摘要 1198029437
p04 步 25 终局 e_loss 结局 沉没 摘要 1613114666
p05 步 28 终局 e_loss 结局 沉没 摘要 2934909485
p06 步 22 终局 e_loss 结局 沉没 摘要 1979625530
p07 步 25 终局 e_loss 结局 沉没 摘要 844847140
p08 步 25 终局 e_silence 结局 缄默 摘要 4230877648
p09 步 25 终局 e_dark 结局 离港 摘要 3757012698
p10 步 28 终局 e_lit 结局 灯守 摘要 81529623
p11 步 25 终局 e_dark 结局 离港 摘要 3400741328
p12 步 22 终局 e_dark 结局 离港 摘要 2500940853
p13 步 25 终局 e_dark 结局 离港 摘要 1943352585
p14 步 25 终局 e_dark 结局 离港 摘要 3950420436
p15 步 22 终局 e_dark 结局 离港 摘要 3469897896
p16 步 25 终局 e_dark 结局 离港 摘要 3025828969
p17 步 25 终局 e_dark 结局 离港 摘要 1373024092
p18 步 22 终局 e_silence 结局 缄默 摘要 1799670809
p19 步 25 终局 e_dark 结局 离港 摘要 4276896151
p20 步 28 终局 e_dark 结局 离港 摘要 827903905
OK ne_10_3_batch_paths:20 条路径采样完成,摘要去重后非退化
P 层:回放这 20 条序列
core/test/ne_10_3_batch.test.ts 读同一份 JSON,用内核把每条序列回放一遍,断言终局节点、结局、摘要三项全等。它证明的是「S 层采出的序列,在 TS 内核里推进出的终局与 S 层自己算的一样」——跨语言同源的第一道门。
// file: core/test/ne_10_3_batch.test.ts
// tier: P
// verify: node --test 'core/test/ne_10_3_batch.test.ts'
// 卷二 10.3(P 层门禁):回放 S 层采样的 20 条路径,断言终局节点、结局与状态摘要一致。
// 同一份 out/ne_10_3_batch_paths.json 也供 W 层 web/verify_ne10.mjs 注入对照。
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 type { Spec, State } from "../src/inkwell/engine.ts";
import { initState, applyChoice, condOk, nodeById, digest } from "../src/inkwell/engine.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 batch = JSON.parse(readFileSync(join(ROOT, "out/ne_10_3_batch_paths.json"), "utf8")) as {
seed: number;
n: number;
endings: Record<string, number>;
paths: { name: string; seq: string[]; node: string; ending: string; digest: number }[];
};
function replay(seq: string[]): State {
let s = initState(spec);
for (const c of seq) s = applyChoice(spec, s, c);
return s;
}
test("批跑规模与种子:20 条路径、seed=20261016", () => {
assert.equal(batch.seed, 20261016);
assert.equal(batch.n, 20);
assert.equal(batch.paths.length, 20);
});
test("每条路径回放:终局节点、结局、摘要与 S 层一致", () => {
for (const p of batch.paths) {
const s = replay(p.seq);
assert.equal(s.node, p.node, `${p.name} 终局节点不一致`);
assert.equal(nodeById(spec, s.node).ending ?? "", p.ending, `${p.name} 结局不一致`);
assert.equal(digest(s), p.digest, `${p.name} 摘要不一致`);
}
});
test("批跑非退化:20 条摘要互不相同,结局分布覆盖 4 种", () => {
const digs = new Set(batch.paths.map((p) => p.digest));
assert.equal(digs.size, 20, "20 条摘要应互不相同");
assert.equal(Object.keys(batch.endings).length, 4, "应覆盖 4 种结局");
const sum = Object.values(batch.endings).reduce((a, b) => a + b, 0);
assert.equal(sum, 20, "结局分布计数之和应为 20");
});
test("确定性策略路径:25 步走到 e_lit / 灯守 / 4162796975", () => {
let s = initState(spec);
const seq: string[] = [];
for (let i = 0; i < 64; i += 1) {
const n = nodeById(spec, s.node);
if (n.ending) break;
const opts = (n.choices ?? []).filter((c) => condOk(c.when, s));
if (!opts.length) break;
seq.push(opts[0].id);
s = applyChoice(spec, s, opts[0].id);
}
assert.equal(seq.length, 25);
assert.equal(s.node, "e_lit");
assert.equal(nodeById(spec, s.node).ending, "灯守");
assert.equal(digest(s), 4162796975);
});
本机实跑(node --test 'core/test/ne_10_3_batch.test.ts'):
✔ 批跑规模与种子:20 条路径、seed=20261016 (0.458333ms)
✔ 每条路径回放:终局节点、结局、摘要与 S 层一致 (1.695917ms)
✔ 批跑非退化:20 条摘要互不相同,结局分布覆盖 4 种 (0.128583ms)
✔ 确定性策略路径:25 步走到 e_lit / 灯守 / 4162796975 (0.211167ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0
W 层:批跑汇总表
web/verify_ne10.mjs(全文见 10.2)的第三段读同一份 JSON,逐条把序列注入页面,断言 DOM 读回的终局与 JSON 一致。失败时它把失败的选择序列原样打印出来,方便直接重放定位。
// snippet: web/verify_ne10.mjs(10.3 批跑循环节选)
for (const p of batch.paths) {
await page.goto(url);
await page.evaluate(() => window.__ne10.reset());
for (const c of p.seq) await page.click(`[data-choice-id="${c}"]`);
const got = await page.evaluate(() => ({ node: window.__ne10.node, ending: window.__ne10.ending, digest: window.__ne10.digest }));
const ok = got.node === p.node && got.ending === p.ending && got.digest === p.digest;
if (ok) pass += 1;
else { fail += 1; console.log(`FAIL ${p.name} 重放:${JSON.stringify(p.seq)}`); }
}
本机实跑(NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne10.mjs 的 10.3 段):
== 10.3 多路径批跑汇总
OK p01 步 22 终局 e_dark 结局 离港 摘要 3976226298
OK p02 步 25 终局 e_dark 结局 离港 摘要 3848604931
OK p03 步 28 终局 e_dark 结局 离港 摘要 1198029437
OK p04 步 25 终局 e_loss 结局 沉没 摘要 1613114666
OK p05 步 28 终局 e_loss 结局 沉没 摘要 2934909485
OK p06 步 22 终局 e_loss 结局 沉没 摘要 1979625530
OK p07 步 25 终局 e_loss 结局 沉没 摘要 844847140
OK p08 步 25 终局 e_silence 结局 缄默 摘要 4230877648
OK p09 步 25 终局 e_dark 结局 离港 摘要 3757012698
OK p10 步 28 终局 e_lit 结局 灯守 摘要 81529623
OK p11 步 25 终局 e_dark 结局 离港 摘要 3400741328
OK p12 步 22 终局 e_dark 结局 离港 摘要 2500940853
OK p13 步 25 终局 e_dark 结局 离港 摘要 1943352585
OK p14 步 25 终局 e_dark 结局 离港 摘要 3950420436
OK p15 步 22 终局 e_dark 结局 离港 摘要 3469897896
OK p16 步 25 终局 e_dark 结局 离港 摘要 3025828969
OK p17 步 25 终局 e_dark 结局 离港 摘要 1373024092
OK p18 步 22 终局 e_silence 结局 缄默 摘要 1799670809
OK p19 步 25 终局 e_dark 结局 离港 摘要 4276896151
OK p20 步 28 终局 e_dark 结局 离港 摘要 827903905
批跑通过 20/20
== verify_ne10: 失败 0
判读:批跑证明了什么
「批跑通过 20/20」 与 P 层的「pass 4 / fail 0」指向同一件事:20 条由固定种子采出的序列,在 Python 侧算出的终局(节点 / 结局 / 摘要),在浏览器里注入点击后逐条重现。三处(S 采样、P 回放、W 注入)用的是同算法同种子,所以对照才有意义。步数区间 22–28、20 条摘要去重后仍是 20,说明采样没有退化成「都走同一条」——P 层的非退化断言正是在守这一条。
结局分布 {"沉没": 4, "灯守": 1, "离港": 13, "缄默": 2} 是采样口径下的分布,只做相对比较、不做绝对判断——均匀采样 20 条得到这样的分布,不等于「游戏设计偏向离港」,那要看穷举口径下的分布(卷三)。这里它只用来确认「采样覆盖了多个结局」,不声称任何体验结论。
失败时的可复现命令:批跑失败会把整条选择序列 JSON.stringify 出来,直接粘回 p.seq 就能重放同一局——失败信息本身是可执行的,这是批跑比「只报一个红叉」更有用的地方。
阅读导航:上一节:10.2 断言终局状态与结局 · 下一节:11.1 存档的序列化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。