10.2 断言终局状态与结局

把 25 步序列走完后的终局状态读回来断言四件事:终局节点 e_lit、结局 灯守、终局摘要 4162796975(与 Python 与 TS 侧逐位一致)、同一序列跑两遍摘要不变;再截图到 out/shots/ 并给出 PNG 字节数。只对整数状态写逐位一致。

Godot 的选择历史日志 关心的是选择被记下来之后怎么回看——日志存什么、界面怎么翻。本节关心的是另一件事:一条选择序列走完之后,终局是不是设计要的那个。前者是记录,后者是断言;前者给人看,后者给回归测试看。

10.2 断言终局状态与结局

10.1 证明了「走得通」,但没证明「走到对的地方」。终局断言要回答的是四个问题,每一个都能用整数或字符串给出确定答案:

断言期望值来源
终局节点e_lit规格里 endings 的灯守节点
结局灯守同上
终局摘要4162796975与 Python / TS 侧逐位一致
两遍渲染确定性摘要两遍相同同一序列跑两次

前两个是业务断言——这局游戏到底通到哪个结局;后两个是工程断言——同一个终局在两次运行里是不是同一个东西。

终局摘要是什么

摘要不是「把终局状态转成一段话」,而是把状态压成一个整数。做法分两步:先把状态写成一条规范串 canonical,再对这条串算 FNV-1a 32 位哈希。规范串由五段用 | 拼成:

段内容本路径的值
节点终局节点 ide_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 多路径批跑 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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