Godot 的选择历史日志 做的是引擎里的选择回看——把玩家走过的选项记下来、存下来、在界面里翻回去看。本节与它划界清楚:这里不是「怎么记录与回看」,而是「把玩家怎么走变成一段可脚本化、可批量、可断言的注入序列」。做法是用无头浏览器把一条完整的选择序列打进页面,逐步读回节点 id 与好感度读数,并断言每一步注入的选项确实落在规格里该节点的可用集合内。
10.1 注入选择序列真玩
第 9 章把规格内联进了单文件 HTML,让人能点着玩。但「点着玩」不是测试——真玩一遍只能证明这一遍没崩。要把「玩家怎么走」变成工程资产,得让选择序列本身可注入:给定一串选项 id,脚本能替玩家把它点完,并在每一步读回状态。
这一步的关键是选择器契约:页面不能只把按钮画出来,还得给每个可注入的位置挂上稳定的 data-* 属性。契约一旦固定,注入脚本就与页面实现解耦——页面换了样式、换了框架,只要契约还在,脚本不动。
| 选择器 | 挂在什么上 | 值 | 注入脚本用它做什么 |
|---|---|---|---|
[data-node-id] | 当前节点卡片 | 节点 id | 读回「现在走到哪个节点」 |
[data-choice-id] | 每个选项按钮 | 选项 id | 作为点击注入的目标 |
[data-choice-available] | 每个选项按钮 | 1 / 0 | 该选项在当前状态是否可用 |
[data-aff] | 好感度卡片 | 好感度的 JSON | 读回当前好感度读数 |
[data-ending] | 终局卡片 | 结局名 | 读回终局结局 |
[data-digest] | 终局卡片 | 状态摘要 | 读回终局摘要 |
可用集合这一栏是本节断言的核心:注入序列里的每一个选项,都必须出现在它所在节点的可用集合里。否则就是在点一个当前状态根本不允许的选项——那是脚本的错,不是页面的错。
契约只有六个属性,却把「页面是什么样」与「脚本怎么驱动」彻底分开。脚本从不 querySelector 一个按钮的类名或文案,只认 data-*;页面也从不关心驱动它的是人还是脚本。这条边界在叙事引擎里尤其重要:对话页面的样式几乎一定会改(换皮、换布局、换字体),但注入契约可以一直不变,回归测试因此不会因为改样式而红。
W 层:可注入的页面
第 9 章的可玩原型页落在 web/dist/ne09.html;本节不依赖它,而是自己构建自己的页面 web/dist/ne10.html——注入脚本要断言的是选择器契约,不是某一个页面的实现。web/build_ne10.mjs 复用 web/pack.mjs 的 bundle / page / writePage / inlineConfig,把 core/src/inkwell/engine.ts 与 config/story_spec.json 内联进页面。页面把每个选项都渲染成按钮,按当前状态写 data-choice-available,不可用的按钮 disabled;再把当前节点、好感度、终局与摘要分别挂到对应的 data-* 上。
// file: web/build_ne10.mjs
// tier: W
// verify: node web/build_ne10.mjs
// 卷二 10.1:把 core/src/inkwell/engine.ts + config/story_spec.json 打成单文件 HTML。
// 页面用 data-node-id / data-choice-id / data-aff 三个选择器契约暴露可注入的接口,
// 供 web/verify_ne10.mjs 注入选择序列真玩、断言终局状态与结局、批跑多路径。
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8"));
const core = bundle(ROOT, ["core/src/inkwell/engine.ts"]);
const pageScript = `
const SPEC = ${inlineConfig(spec)};
let state = initState(SPEC);
function render() {
const n = nodeById(SPEC, state.node);
const host = document.querySelector("[data-node-id]");
host.dataset.nodeId = n.id;
host.textContent = n.id;
document.querySelector("[data-speaker]").textContent = n.speaker;
document.querySelector("[data-line]").textContent = n.line;
const box = document.querySelector("[data-choices]");
box.textContent = "";
for (const c of n.choices || []) {
const b = document.createElement("button");
b.className = "choice";
b.dataset.choiceId = c.id;
const ok = condOk(c.when, state);
b.dataset.choiceAvailable = ok ? "1" : "0";
b.disabled = !ok;
b.textContent = c.text;
b.addEventListener("click", () => { state = applyChoice(SPEC, state, c.id); render(); });
box.appendChild(b);
}
const aff = document.querySelector("[data-aff]");
aff.dataset.aff = JSON.stringify(state.aff);
aff.textContent = Object.keys(state.aff).sort().map((k) => k + " " + state.aff[k]).join(" ");
document.querySelector("[data-ending]").textContent = n.ending || "(未终局)";
document.querySelector("[data-digest]").textContent = String(digest(state));
}
window.__ne10 = {
get node() { return state.node; },
get aff() { return state.aff; },
get flags() { return state.flags; },
get items() { return state.items; },
get path() { return state.path.slice(); },
get digest() { return digest(state); },
get canonical() { return canonical(state); },
get ending() { return nodeById(SPEC, state.node).ending || ""; },
get available() {
return Array.from(document.querySelectorAll("[data-choice-id]"))
.filter((b) => b.dataset.choiceAvailable === "1")
.map((b) => b.dataset.choiceId);
},
reset() { state = initState(SPEC); render(); },
};
render();
`;
const body = `
<h1>雾港夜航 · 注入真玩原型</h1>
<div class="sub">core/src/inkwell/engine.ts 内联 · 同一份 story_spec.json · data-choice-id 选择器契约</div>
<div class="card"><div class="k">当前节点</div><div class="big" data-node-id></div></div>
<div class="line"><span class="who" data-speaker></span> <span data-line></span></div>
<div data-choices></div>
<div class="card"><div class="k">好感度</div><div data-aff></div></div>
<div class="card"><div class="k">终局</div><div data-ending></div><div class="k">digest</div><div data-digest></div></div>
`;
const html = page({ title: "雾港夜航 · 注入真玩原型", body, script: core + "\n\n" + pageScript });
console.log("wrote web/dist/ne10.html", writePage(join(HERE, "dist/ne10.html"), html), "bytes");
本机实跑(node web/build_ne10.mjs):
wrote web/dist/ne10.html 41406 bytes
页面里 window.__ne10.available 就是从 DOM 反查出来的可用集合——它和规格侧算出来的集合必须一致,这是本节断言的一半。
为什么是「注入」而不是「单元测试」
有人会问:状态推进的正确性,P 层用 node --test 不已经证过了吗?为什么还要在浏览器里再点一遍?因为两者证明的不是同一件事:
| 问题 | P 层(内核单测) | W 层(无头注入) |
|---|---|---|
| 状态推进算得对吗 | 是 | 间接(页面调的是同一份内核) |
| 页面真的把状态画出来了吗 | 否 | 是(读的是 DOM 上的读数) |
| 可用选项真的按条件禁用了吗 | 否 | 是(读的是 data-choice-available) |
| 按钮点击真的接到了状态机上吗 | 否 | 是(点击真的推进了状态) |
| 换一种渲染实现后行为还一致吗 | 否 | 是(契约不变则断言不变) |
P 层证明「这台状态机自洽」,W 层证明「这台状态机被装进页面后仍然自洽」。中间那层「内联、渲染、绑定事件」是 P 层碰不到的地方,也正是注入脚本要守住的地方。
注入序列从哪来:策略,不是清单
序列由「总是选第一个可用选项」的确定性策略现算,而不是手写一张 25 项的清单。原因有两个:其一,手写清单会随规格漂移——规格里改一个 goto,清单里对应那一步就指到了错的节点,而策略永远不会;其二,策略本身是可验证的对象——「总是选第一个可用」这个规则写死在一处,规格变则序列变,脚本的其余部分一行不改。
这条确定性策略的产物正好是贯穿全书的那个最小回环:25 步、终局 e_lit、摘要 4162796975。它不是随便挑的一条路径,而是「每个岔口都走第一支」这条最短的确定性路径——同一策略在 Python 侧(sim/ne_00_toc_engine.py)与浏览器侧算出的终局摘要逐位相同,这正是本节末尾 10.2 要断言的锚点。
W 层:注入 25 步序列
注入脚本 web/verify_ne10.mjs 先按「总是选第一个可用选项」的确定性策略在规格侧走出一条序列,再打开页面把它逐步点进去。每一步都做两件事:把 DOM 的可用集合与规格侧算出的可用集合作比对;点完之后读回节点 id 与好感度读数。
// snippet: web/verify_ne10.mjs(10.1 注入循环节选)
const seq = specFirstPath();
check("注入序列长度", seq.length, 25);
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}`);
}
本机实跑(node web/build_ne10.mjs && NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne10.mjs 的 10.1 段,每行一步):
OK 注入序列长度: 25
== 10.1 注入序列逐步轨迹(节点 / 点击 / 到达 / 好感度)
OK step 1 d_pier_1 可用2 注入 c_pier_1a → d_pier_2 好感度 hui=50 mu=50 shen=50 yan=20
OK step 2 d_pier_2 可用2 注入 c_pier_2a → d_tavern_1 好感度 hui=50 mu=50 shen=50 yan=20
OK step 3 d_tavern_1 可用3 注入 c_tavern_1a → d_tavern_2 好感度 hui=55 mu=50 shen=50 yan=20
OK step 4 d_tavern_2 可用3 注入 c_tavern_2a → d_tavern_3 好感度 hui=60 mu=50 shen=50 yan=20
OK step 5 d_tavern_3 可用2 注入 c_tavern_3a → d_hub_a1 好感度 hui=60 mu=50 shen=50 yan=20
OK step 6 d_hub_a1 可用2 注入 c_hub_a1a → d_ch_1 好感度 hui=60 mu=50 shen=50 yan=20
OK step 7 d_ch_1 可用3 注入 c_ch_1a → d_ch_2 好感度 hui=60 mu=50 shen=50 yan=20
OK step 8 d_ch_2 可用2 注入 c_ch_2a → d_ch_3 好感度 hui=60 mu=55 shen=50 yan=20
OK step 9 d_ch_3 可用1 注入 c_ch_3a → d_hub_a2 好感度 hui=60 mu=55 shen=50 yan=20
OK step 10 d_hub_a2 可用2 注入 c_hub_a2a → d_lb_1 好感度 hui=60 mu=55 shen=50 yan=20
OK step 11 d_lb_1 可用2 注入 c_lb_1a → d_lb_2 好感度 hui=60 mu=55 shen=50 yan=20
OK step 12 d_lb_2 可用2 注入 c_lb_2a → d_lb_3 好感度 hui=60 mu=55 shen=50 yan=25
OK step 13 d_lb_3 可用2 注入 c_lb_3a → d_tc_1 好感度 hui=60 mu=55 shen=50 yan=25
OK step 14 d_tc_1 可用2 注入 c_tc_1a → d_tc_2 好感度 hui=60 mu=55 shen=50 yan=25
OK step 15 d_tc_2 可用2 注入 c_tc_2a → d_tc_3 好感度 hui=60 mu=55 shen=50 yan=35
OK step 16 d_tc_3 可用2 注入 c_tc_3a → d_ho_1 好感度 hui=60 mu=55 shen=50 yan=35
OK step 17 d_ho_1 可用3 注入 c_ho_1a → d_ho_2 好感度 hui=60 mu=60 shen=50 yan=35
OK step 18 d_ho_2 可用2 注入 c_ho_2a → d_ho_3 好感度 hui=60 mu=65 shen=50 yan=35
OK step 19 d_ho_3 可用1 注入 c_ho_3a → d_hub_b2 好感度 hui=60 mu=65 shen=50 yan=35
OK step 20 d_hub_b2 可用2 注入 c_hub_b2a → d_lt_1 好感度 hui=60 mu=65 shen=50 yan=35
OK step 21 d_lt_1 可用2 注入 c_lt_1b → d_lt_2 好感度 hui=60 mu=65 shen=50 yan=45
OK step 22 d_lt_2 可用3 注入 c_lt_2a → d_lt_3 好感度 hui=60 mu=65 shen=50 yan=55
OK step 23 d_lt_3 可用3 注入 c_lt_3a → d_dq_1 好感度 hui=60 mu=65 shen=50 yan=65
OK step 24 d_dq_1 可用1 注入 c_dq_1c → d_dq_2 好感度 hui=60 mu=65 shen=50 yan=65
OK step 25 d_dq_2 可用4 注入 c_dq_2a → e_lit 好感度 hui=60 mu=65 shen=50 yan=65
判读:注入序列与可用集合的一致性
25 步全部 OK 说明两件事同时成立:① 注入的每一个选项 id 都真实存在于它所在节点的按钮里;② 每一步的 DOM 可用集合与规格侧(同一份 engine.ts)算出的可用集合逐项相同。第 9 步与第 19 步的「可用1」是规格里的窄门——那两处只有一个选项可选,注入序列没有别的走法。
好感度读数是路径的指纹。第 3–5 步 hui 从 50 累加到 60,第 17–18 步 mu 从 55 走到 65,第 21–23 步 yan 从 35 跳到 65——这些整数每一次变化都对应一个选项的 add 效果,且全部落在 [0,100] 内。脚本读的是页面 DOM 上的读数,不是自己算的,所以它证明的是「页面真的按规格推进了状态」,而不只是「脚本自己算对了」。
注入序列可复现。序列不是手写的常量,而是用「总是选第一个可用选项」的确定性策略现算出来的——换一份规格,序列自动跟着变,脚本一行不用改。这正是把「玩家怎么走」脚本化的意义:路径是策略的产物,不是硬编码的清单。
这一步只证明「走得通」。25 步走完、可用集合逐步一致,说明页面能吃下一条序列并推进状态;但它还没有对终局下任何结论——终局节点是不是 e_lit、摘要是不是 4162796975、同一序列跑两遍是否一致,都留到下一节。把「走得通」与「走到对的地方」分成两步,是为了让失败时能一眼看出是注入环节坏了还是终局断言坏了。
三个易踩的工程坑
无头注入看着简单,真跑起来有三处反复踩:
- 冷启动首个 page 读 canvas 像素恒为空白。Playwright 起浏览器后,第一个
page上做像素读取会拿到全空——这跟本页无关,是启动时序。规避办法是先开一个预热页:画一次 8×8 再关掉,之后正常页的绘制才可信。因此本节与 10.2 的「已绘制」断言只写字节数大于 0,绝不写「等于 0」的反向断言——空白与失败是两回事。 NODE_PATH对 ESMimport无效。Playwright 全局装在/opt/homebrew/lib/node_modules,但import { chromium } from "playwright"不会去那里找。必须在.mjs里createRequire(import.meta.url)拿到 CJS 的require,再require("playwright")。- Node 26 是 strip-only。
enum/namespace一律报ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,页面脚本与验证脚本里都不能出现;类型断言、参数属性、装饰器同理。 - 别把截图 Buffer 直接
console.log。page.screenshot()同时返回 Buffer 并写文件;直接打印会把二进制灌进输出、污染后续所有解析,正确做法是只打.length(10.2 输出的「43536 bytes」就是这么来的)。
自检表
| 检查项 | 合格标准 |
|---|---|
| 契约完整 | 六个 data-* 选择器都挂到了页面上 |
| 序列长度 | 注入序列恰好 25 步(脚本内断言) |
| 逐步一致 | 每一步 DOM 可用集合等于规格侧可用集合 |
| 读数真实 | 节点 id 与好感度都从 DOM 读回,不是脚本自算 |
| 冷启动 | 先开预热页;已绘制断言写「字节数 > 0」 |
| 依赖取用 | 全局 Playwright 经 createRequire 取,不用 import |
一句话:注入把「玩家怎么走」从人的手指里拿出来,变成一段可复现的脚本。这一段脚本是 10.2 终局断言与 10.3 批跑的公共底座——三节共用一个 web/verify_ne10.mjs,一次跑完。
下一节把这条序列走完之后的状态读回来做断言:终局节点、结局、以及终局摘要。
阅读导航:上一节:9.3 原型与内核的同源 · 下一节:10.2 断言终局状态与结局 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。