10.1 注入选择序列真玩

用 Playwright 无头打开 web/dist/ne10.html,靠 data-choice-id 选择器契约注入一条 25 步的完整选择序列真玩一遍:逐步记录节点 id 与当前好感度读数,并断言每一步注入的选项都落在规格该节点的可用集合里,终局摘要 4162796975。

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 对 ESM import 无效。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 断言终局状态与结局 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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