9.3 原型与内核的同源

浏览器里走完同一条确定性策略路径(总是选第一个可用选项,共 25 个事件),读出终局状态,与 Python 侧产物比对 canonical 串与 FNV-1a 摘要。三侧锚点一致:摘要 4162796975、节点 e_lit、结局 灯守。只对整数状态写逐位一致。

Phaser 过场系统 把剧情「演」出来,它是一套编排逻辑;本节要证明的是另一件事:这页可玩原型里的推进逻辑,与命令行里那个内核是同一个东西,不是为浏览器重写的迷你版。9.1 内联的就是 core/src/inkwell/engine.ts 本身;这一节用一条确定性路径,把这件事变成一个可比对的整数。

9.3 原型与内核的同源

「同源」不是一句口号,它要能被断言。做法是选一条确定性策略路径——「总是选第一个可用选项」——让三个宿主各走一遍:

侧宿主走的策略取什么
Pythonsim/ne_01_3_same_origin.py 落 out/ne_01_3_py.jsonfirstcanonical 串 + FNV-1a 摘要
TypeScriptcore/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
24 个好感度(键名排序,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 注入选择序列真玩 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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