Godot 的选择历史日志 关心的是选择被记下来之后怎么回看——日志存什么、界面怎么翻。本节关心的是另一件事:一条选择序列走完之后,终局是不是设计要的那个。前者是记录,后者是断言;前者给人看,后者给回归测试看。
10.2 断言终局状态与结局
10.1 证明了「走得通」,但没证明「走到对的地方」。终局断言要回答的是四个问题,每一个都能用整数或字符串给出确定答案:
| 断言 | 期望值 | 来源 |
|---|---|---|
| 终局节点 | e_lit | 规格里 endings 的灯守节点 |
| 结局 | 灯守 | 同上 |
| 终局摘要 | 4162796975 | 与 Python / TS 侧逐位一致 |
| 两遍渲染确定性 | 摘要两遍相同 | 同一序列跑两次 |
前两个是业务断言——这局游戏到底通到哪个结局;后两个是工程断言——同一个终局在两次运行里是不是同一个东西。
终局摘要是什么
摘要不是「把终局状态转成一段话」,而是把状态压成一个整数。做法分两步:先把状态写成一条规范串 canonical,再对这条串算 FNV-1a 32 位哈希。规范串由五段用 | 拼成:
| 段 | 内容 | 本路径的值 |
|---|---|---|
| 节点 | 终局节点 id | e_lit |
| 好感度 | 四个变量按键名排序 | hui=60,mu=65,shen=50,yan=65 |
| 置位 flag | 只列置 1 的 flag,排序 | cave_explored,found_yan_journal,… |
| 持有物品 | 只列持有数非 0 的,排序 | journal,ledger_page,rope_knot,salt_letter |
| 路径 | 走过的节点用 > 连起来 | d_pier_1>…>d_dq_2>e_lit |
「按键名排序」这一条是跨语言一致的关键:Python 的 dict 与 JS 的 object 键序未必相同,但只要两侧都先排序再拼串,得到的规范串就逐字节相同,摘要也就逐位相同。排序让摘要与「谁先写进对象」无关。
四条断言缺一不可
只断言结局不够——结局是一个粗粒度的标签,五种结局对应五种终局,但同一个结局可以由多条不同路径走到(10.3 会看到 13 条路径都通到「离港」)。所以要同时断言节点、结局、摘要三层:节点保证走到的是同一个终局场景,结局保证业务语义正确,摘要保证一路上的状态(好感度、flag、物品)逐位相同。少了摘要这一层,两条不同的路径会被误判成同一个结果。
两遍确定性则是第四层:它防的不是「算错了」,而是「算得不确定」——同一输入两次跑出不同摘要,说明有隐藏状态泄漏进了推进逻辑。这一层只有真的跑两遍才抓得到,静态读代码看不出来。
W 层:验证脚本全文
web/verify_ne10.mjs 一次跑完三节:10.1 逐步注入并读回轨迹,10.2 读终局并断言、再跑一遍验确定性、截图,10.3 批跑 20 条路径与 Python 侧对照。它把 ../core/src/inkwell/engine.ts 直接 import 进来做规格侧计算——因为 Node 26 原生支持 .ts,规格侧的可用集合与摘要都由同一份内核算出,页面与脚本没有第二套实现。
// file: web/verify_ne10.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne10.mjs
// 卷二 10.1/10.2/10.3:Playwright 无头打开 ne10.html,注入 25 步选择序列真玩,
// 逐步断言可用选项集合、终局状态与结局、两遍确定性,再批跑 20 条路径与 Python 侧对照。
// NODE_PATH 对 ESM import 无效 → 必须 createRequire;规格侧复用同一份 engine.ts。
import { createRequire } from "node:module";
import { readFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { initState, applyChoice, condOk, nodeById, digest } from "../core/src/inkwell/engine.ts";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const SHOTS = join(ROOT, "out/shots");
mkdirSync(SHOTS, { recursive: true });
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8"));
const batch = JSON.parse(readFileSync(join(ROOT, "out/ne_10_3_batch_paths.json"), "utf8"));
const url = pathToFileURL(join(HERE, "dist/ne10.html")).href;
// 规格侧的可用选项集合:与页面同一份 engine.ts,故两侧集合必须相同
function specAvailable(state) {
const n = nodeById(spec, state.node);
return (n.choices || []).filter((c) => condOk(c.when, state)).map((c) => c.id);
}
// 规格侧的确定性策略:总是选第一个可用选项,走出一条完整序列
function specFirstPath() {
let s = initState(spec);
const seq = [];
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);
}
return seq;
}
let fail = 0;
function check(name, got, want) {
const ok = got === want;
if (!ok) fail += 1;
console.log(`${ok ? "OK " : "FAIL"} ${name}: ${got}${ok ? "" : " != " + want}`);
}
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 820, height: 1000 } });
const page = await ctx.newPage();
// 冷启动预热页:首个 page 读像素恒为空白,先画一次 8x8 再丢弃
const warm = await ctx.newPage();
await warm.setContent('<canvas id="w" width="8" height="8"></canvas>');
await warm.evaluate(() => {
const g = document.getElementById("w").getContext("2d");
g.fillStyle = "#38bdf8";
g.fillRect(0, 0, 8, 8);
});
await warm.close();
// ---- 10.1 注入选择序列真玩:逐步断言可用集合,记录每步节点与好感度 ----
const seq = specFirstPath();
check("注入序列长度", seq.length, 25);
console.log("== 10.1 注入序列逐步轨迹(节点 / 点击 / 到达 / 好感度)");
await page.goto(url);
await page.evaluate(() => window.__ne10.reset());
let sspec = initState(spec);
for (let i = 0; i < seq.length; i += 1) {
const dom = await page.evaluate(() => ({ node: window.__ne10.node, available: window.__ne10.available }));
const want = specAvailable(sspec);
const same = want.length === dom.available.length && want.every((x) => dom.available.includes(x));
if (!same) fail += 1;
await page.click(`[data-choice-id="${seq[i]}"]`);
sspec = applyChoice(spec, sspec, seq[i]);
const after = await page.evaluate(() => ({ node: window.__ne10.node, aff: window.__ne10.aff }));
const a = Object.keys(after.aff).sort().map((k) => `${k}=${after.aff[k]}`).join(" ");
console.log(`${same ? "OK " : "FAIL"} step ${String(i + 1).padStart(2)} ${dom.node} 可用${dom.available.length} 注入 ${seq[i]} → ${after.node} 好感度 ${a}`);
}
// ---- 10.2 断言终局状态与结局 ----
console.log("== 10.2 终局断言");
const fin = await page.evaluate(() => ({
node: window.__ne10.node, ending: window.__ne10.ending,
digest: window.__ne10.digest, canonical: window.__ne10.canonical,
}));
check("终局节点", fin.node, "e_lit");
check("结局", fin.ending, "灯守");
check("终局摘要(浏览器 vs 期望)", fin.digest, 4162796975);
check("终局摘要(浏览器 vs 规格侧 engine.ts)", fin.digest, digest(sspec));
await page.goto(url);
await page.evaluate(() => window.__ne10.reset());
for (const c of seq) await page.click(`[data-choice-id="${c}"]`);
const d2 = await page.evaluate(() => window.__ne10.digest);
check("两遍渲染确定性", d2, 4162796975);
const shot = await page.screenshot({ path: join(SHOTS, "ne10_final_lit.png"), fullPage: true });
console.log(`OK 截图 out/shots/ne10_final_lit.png ${shot.length} bytes`);
if (shot.length <= 0) { fail += 1; console.log("FAIL 截图为空"); }
// ---- 10.3 多路径批跑:20 条采样序列与 Python 侧对照 ----
console.log("== 10.3 多路径批跑汇总");
let pass = 0;
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} 浏览器 node=${got.node} ending=${got.ending} digest=${got.digest} | py node=${p.node} ending=${p.ending} digest=${p.digest}`);
console.log(` 重放:${JSON.stringify(p.seq)}`);
}
console.log(`${ok ? "OK " : "FAIL"} ${p.name} 步 ${String(p.seq.length).padStart(2)} 终局 ${got.node} 结局 ${got.ending} 摘要 ${got.digest}`);
}
console.log(`批跑通过 ${pass}/${batch.paths.length}`);
await browser.close();
console.log(`== verify_ne10: 失败 ${fail}`);
process.exit(fail ? 1 : 0);
实跑输出:10.2 终局断言段
== 10.2 终局断言
OK 终局节点: e_lit
OK 结局: 灯守
OK 终局摘要(浏览器 vs 期望): 4162796975
OK 终局摘要(浏览器 vs 规格侧 engine.ts): 4162796975
OK 两遍渲染确定性: 4162796975
OK 截图 out/shots/ne10_final_lit.png 43536 bytes
与 P 层终局断言的分工
同一个终局摘要,P 层(core/test/ne_10_3_batch.test.ts)与 W 层都会断言,但断言的位置不同:
| P 层 | W 层 | |
|---|---|---|
| 摘要从哪来 | 直接调 digest(state) | 从 DOM 的 [data-digest] 读回 |
| 覆盖的环节 | 内核逻辑 | 内核 + 内联 + 渲染 + 事件绑定 |
| 抓得到的错 | 求值 / 效果 / 摘要算法 | 上面全部,外加「页面没把状态画出来」 |
| 抓不到的错 | 页面层 | 内核里没有 DOM 的那部分 |
两层断言的期望值必须相同:都指向 4162796975。若 W 层读到的数跟 P 层不一样,说明内核到页面之间出了问题(内联错版本、事件没绑上、渲染读的是旧状态);若两层都一样但都跟规格设计不符,那是规格本身的问题——那属于卷一的三层校验,不在本节。
判读:摘要断言的是状态,不是文本
「终局摘要(浏览器 vs 规格侧 engine.ts): 4162796975」 是本节最有分量的那一条。左边是浏览器里内联的 engine.ts 算出来的,右边是脚本 import 的同一份 engine.ts 算出来的——两边都是整数,所以可以直接比对、可以断言相等。摘要的输入是规范串 e_lit|hui=60,mu=65,shen=50,yan=65|…,由节点、四个好感度、置位 flag、持有物品、路径五段拼成,只吃状态、不吃文本。
这是为什么本节只对整数状态写「逐位一致」,绝不对文本写「逐字相等」。 文本涉及编码、换行、空格,把「文本逐字相等」当判据是不可靠的——把一句台词换成另一句,状态摘要不变,因为文本是规格的渲染产物,不是状态本身。所以本节所有一致性断言的对象都是 canonical 串与它的 FNV-1a 摘要,一个字都不碰台词。
「两遍渲染确定性: 4162796975」 是重新 goto + reset 后再走一遍同一序列的结果。两遍相同说明渲染与推进都是确定的——没有隐藏的时钟、没有未播种的随机、没有靠遍历顺序碰运气的逻辑。这正是「确定性回放」(第 4 章)在浏览器侧的落点。
「截图 43536 bytes」 是「已绘制」的证据。冷启动首个 page 读像素恒为空白,所以验证脚本先开了一个预热页;即便如此,已绘制断言也只写字节数大于 0,不写「等于 0」的反向断言——空白截图与渲染失败是两回事,断言要抓的是后者。
这四条断言合起来才叫「终局可断言」:节点、结局、摘要、两遍确定性。任何一条单独拿出来都能被绕过——只断结局,两条不同路径会蒙混过关;只断摘要,业务语义没验证;只断一遍,不确定性溜走。四条一起,才把「这条路径通到这个地方、且每次都通到同一个地方」钉死。
截图落在 out/shots/ne10_final_lit.png,路径里带 lit(灯守)是刻意的:截图文件名即断言结论,翻一眼目录就知道这局通到了哪个结局,不必打开图片。
自检表
| 检查项 | 合格标准 |
|---|---|
| 终局节点 | 读到 e_lit |
| 结局 | 读到 灯守 |
| 终局摘要 | 浏览器 = 规格侧 engine.ts = 4162796975 |
| 两遍确定性 | 同序列两遍摘要相同 |
| 已绘制 | 截图字节数 > 0,不写反向断言 |
| 摘要口径 | 只对整数状态做摘要,不对文本做 |
一条路径通了,只证明这一条。 真实的回归价值在于「一批路径一起通」——这正是下一节要做的事。
下一节把「一条序列」扩成「一批序列」:用固定种子采样 20 条路径,逐条断言注入结果与 Python 侧一致。
阅读导航:上一节:10.1 注入选择序列真玩 · 下一节:10.3 多路径批跑 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。