Phaser 过场系统
把剧情「演」出来,它是一套编排逻辑;本节要证明的是另一件事:这页可玩原型里的推进逻辑,与命令行里那个内核是同一个东西,不是为浏览器重写的迷你版。9.1 内联的就是 core/src/inkwell/engine.ts 本身;这一节用一条确定性路径,把这件事变成一个可比对的整数。
9.3 原型与内核的同源
「同源」不是一句口号,它要能被断言。做法是选一条确定性策略路径——「总是选第一个可用选项」——让三个宿主各走一遍:
| 侧 | 宿主 | 走的策略 | 取什么 |
|---|---|---|---|
| Python | sim/ne_01_3_same_origin.py 落 out/ne_01_3_py.json | first | canonical 串 + FNV-1a 摘要 |
| TypeScript | core/test/ne_01_3_same_origin.test.ts | 重放同一条选择序列 | canonical 串 + 摘要 |
| 浏览器 | web/verify_ne09.mjs 驱动 web/dist/ne09.html | 点击首个 [data-choice-id] | canonical 串 + 摘要 |
三侧若同源,则终局状态的 canonical 串与摘要必须逐位相同。这条策略不含随机:每次都取第一个可用选项,同一条路径每次跑都一样。
口径纪律:只对整数状态写「逐位一致」,不写「文本逐字相等」。 摘要的输入是 canonical 串(节点 / 好感度 / flag / 物品 / 路径),全是 ASCII 标识符与整数;台词文本不进摘要,也不做逐字比对——文本涉及编码与换行,把它当判据是不可靠的。
为什么这条策略不含随机
路径采样通常要用可播种的伪随机(Lehmer MINSTD),但同源验证不需要随机——随机只会引入一个「两侧种子是否一致」的额外变量。所以本节选一条纯确定性的策略:每次都在当前可用选项里取第一个。
| 策略 | 是否随机 | 用途 |
|---|---|---|
| 总是选第一个可用 | 否 | 同源验证(本节) |
| 均匀随机采样 | 是(可播种) | 覆盖与分布(卷三) |
| 选最后一个 / 交替 | 否 | 多策略对照(1.3) |
不含随机的路径每次跑都一样,因此「浏览器 vs Python」的比对里,唯一的变量就是宿主。若两侧不一致,原因只可能是实现不同源——这正是本节要证伪的。
canonical 串的四段
规范串由竖线分成五段,前四段是状态、末段是路径:
| 段 | 内容 | 例(本节终局) |
|---|---|---|
| 1 | 终局节点 | e_lit |
| 2 | 4 个好感度(键名排序,k=v) | hui=60,mu=65,shen=50,yan=65 |
| 3 | 置位 flag(排序) | cave_explored,found_yan_journal,… |
| 4 | 持有物品(排序) | journal,ledger_page,rope_knot,salt_letter |
| 5 | 路径(> 连接) | d_pier_1>…>e_lit |
四段全是 ASCII 标识符与整数——没有一个字来自台词。这就是摘要可以做跨语言逐位比对、而文本不能的前提。
W 层:浏览器走完同一条策略路径
web/verify_ne09.mjs 在 9.2 的界面断言之后,重置到起点,循环「读首个 data-choice-id → 点击」,直到当前节点没有可用选项(即到达结局),统计点击次数,读出终局 canonical 与摘要,与 Python 侧产物里 first 策略那一行逐位比对。
// file: web/verify_ne09.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne09.mjs
// 卷二 9.2 / 9.3:Playwright 无头打开单文件原型,断言 DOM 已绘制、按钮数 = 当前可用
// 选项数、点击推进节点;再走「总是选第一个可用选项」的确定性路径(25 事件),把终局
// canonical 与 FNV-1a 摘要与 Python 侧(out/ne_01_3_py.json 的 first 策略)逐位比对。
// NODE_PATH 对 ESM import 无效 → 必须 createRequire。
import { createRequire } from "node:module";
import { readFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
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 py = JSON.parse(readFileSync(join(ROOT, "out/ne_01_3_py.json"), "utf8"));
const ref = py.find((r) => r.strategy === "first");
const url = pathToFileURL(join(HERE, "dist/ne09.html")).href;
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 820, height: 900 } });
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();
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}`);
}
await page.goto(url);
const start = await page.evaluate(() => ({
node: window.__ne09.node, choices: window.__ne09.choices, available: window.__ne09.available,
}));
check("起始节点", start.node, "d_pier_1");
check("按钮数 = 当前可用选项数", start.choices, start.available);
const firstId = await page.getAttribute("[data-choice-id]", "data-choice-id");
await page.click(`[data-choice-id="${firstId}"]`);
check("点击后节点推进", await page.evaluate(() => window.__ne09.node) !== start.node, true);
const shot = await page.screenshot({ path: join(SHOTS, "ne09_playable.png"), fullPage: true });
console.log(`OK 截图 out/shots/ne09_playable.png ${shot.length} bytes`);
if (shot.length <= 0) { fail += 1; console.log("FAIL 截图为空"); }
await page.goto(url);
await page.evaluate(() => window.__ne09.reset());
let steps = 0;
for (let i = 0; i < 200; i += 1) {
if ((await page.evaluate(() => window.__ne09.choices)) === 0) break;
const cid = await page.getAttribute("[data-choice-id]", "data-choice-id");
await page.click(`[data-choice-id="${cid}"]`);
steps += 1;
}
const got = await page.evaluate(() => ({
node: window.__ne09.node, ending: window.__ne09.ending,
canonical: window.__ne09.canonical, digest: window.__ne09.digest,
}));
check("策略路径事件数", steps, ref.choices.length);
check("终局节点", got.node, ref.node);
check("结局", got.ending, ref.ending);
check("canonical(浏览器 vs Python)", got.canonical, ref.canonical);
check("FNV-1a 摘要(浏览器 vs Python)", got.digest, ref.digest);
check("摘要锚点 4162796975", got.digest, 4162796975);
await browser.close();
console.log(`== verify_ne09: 失败 ${fail}`);
process.exit(fail ? 1 : 0);
本机实跑,同源比对部分(9.3):
OK 策略路径事件数: 25
OK 终局节点: e_lit
OK 结局: 灯守
OK canonical(浏览器 vs Python): e_lit|hui=60,mu=65,shen=50,yan=65|cave_explored,found_yan_journal,knows_tide_rule,knows_yan_missing,met_hai,met_hui,met_mu,saw_lighthouse_light|journal,ledger_page,rope_knot,salt_letter|d_pier_1>d_pier_2>d_tavern_1>d_tavern_2>d_tavern_3>d_hub_a1>d_ch_1>d_ch_2>d_ch_3>d_hub_a2>d_lb_1>d_lb_2>d_lb_3>d_tc_1>d_tc_2>d_tc_3>d_ho_1>d_ho_2>d_ho_3>d_hub_b2>d_lt_1>d_lt_2>d_lt_3>d_dq_1>d_dq_2>e_lit
OK FNV-1a 摘要(浏览器 vs Python): 4162796975
OK 摘要锚点 4162796975: 4162796975
== verify_ne09: 失败 0
TypeScript 侧的证据
浏览器侧与 Python 侧对齐后,还差一环:TypeScript 侧。core/test/ne_01_3_same_origin.test.ts 读同一份 Python 产物 out/ne_01_3_py.json,用 TS 内核 replay() 重放每条选择序列,断言 canonical 与 digest 逐位相同;最后一条测试把锚点写死:
for (const r of rows) {
const s = replay(spec, r.choices);
assert.equal(canonical(s), r.canonical, `${r.strategy} 规范串不一致`);
assert.equal(digest(s), r.digest, `${r.strategy} 摘要不一致`);
assert.equal(s.node, r.node, `${r.strategy} 终局节点不一致`);
}
test("first 策略摘要 = 目录篇 ne_00 的 4162796975", () => {
const first = rows.find((r) => r.strategy === "first");
assert.ok(first);
assert.equal(first.digest, 4162796975);
});
本机实跑 node --test 'core/test/ne_01_3_same_origin.test.ts':
✔ 跨语言同源:Python 四条路径在 TS 侧重放,摘要逐位一致 (3.069209ms)
✔ 四条策略落在三个不同结局上,且四个摘要两两不同 (6.270167ms)
✔ first 策略摘要 = 目录篇 ne_00 的 4162796975 (0.91125ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 311.137708
判读:三侧同值 4162796975
「策略路径事件数: 25」:这条路径恰好 25 个事件(从 d_pier_1 到 e_lit,共 26 个节点)。它与目录篇 sim/ne_00_toc_engine.py 的「选项数 25」是同一件事。
「终局节点: e_lit / 结局: 灯守」:这条确定性路径落在「灯守」结局上——玩家一路选第一个选项,最终留在灯塔守望。
「canonical(浏览器 vs Python)」 是逐位比对的核心:Python 侧产物 out/ne_01_3_py.json 的 first 策略与浏览器侧读出的规范串完全一致。规范串由四段拼成——节点 | 好感度 | 置位 flag | 持有物品 | 路径。
「摘要 4162796975」 是三侧锚点:Python 侧(out/ne_01_3_py.json)、TypeScript 侧(core/test/ne_01_3_same_origin.test.ts 断言「first 策略摘要 = 目录篇 ne_00 的 4162796975」)、浏览器侧(本节)给出同一个值。
| 侧 | 终局节点 | 结局 | 摘要 |
|---|---|---|---|
Python(out/ne_01_3_py.json 的 first) | e_lit | 灯守 | 4162796975 |
TypeScript(ne_01_3_same_origin.test.ts) | e_lit | 灯守 | 4162796975 |
浏览器(verify_ne09.mjs) | e_lit | 灯守 | 4162796975 |
「摘要只覆盖状态,不覆盖文本」:把任何一句台词换成别的文字,这个数都不变——文本是规格的渲染产物,不是状态本身。这也是为什么本节从头到尾只对整数状态说「逐位一致」,一次都没有对文本说「逐字相等」。
一个容易忽略的细节:路径也在规范串里
规范串的第 5 段是路径(d_pier_1>…>e_lit),它不只是「走过哪些节点」的记录——把它算进摘要,意味着两条到达同一节点、但路径不同的状态,摘要也不同。这正好抓住叙事引擎里最要紧的一件事:结局一样,走法可以不一样,而走法本身是体验的一部分。所以本节的摘要比对,比的不是「到没到 e_lit」,而是「以哪条路径到的 e_lit」。
| 若摘要不含路径 | 若摘要含路径(本套) |
|---|---|
| 只区分终局节点与数值 | 还区分到达方式 |
| 可能把不同路径压成同一个值 | 路径不同即摘要不同 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 同一条路径 | 三侧都走「总是选第一个可用选项」,共 25 个事件 |
| canonical 逐位一致 | 浏览器规范串 = Python 侧产物规范串 |
| 摘要逐位一致 | 三侧摘要均为 4162796975 |
| 终局一致 | 节点 e_lit、结局 灯守 |
| 摘要只对状态 | 输入是 canonical 串,不含任何台词文本 |
| 口径纪律 | 只对整数状态写「逐位一致」,不写「文本逐字相等」 |
原型同源证明了,下一章把它变成批量断言:不再只走一条固定策略,而是向无头浏览器注入任意选择序列,断言终局状态与结局,并一次批跑多条路径。
阅读导航:上一节:9.2 选择界面与状态显示 · 下一节:10.1 注入选择序列真玩 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。