《游戏设计》卷一《玩法机制与可执行规格》 的「四处消费」都在同一类运行环境里,比对的是数值;本章的「三处消费」跨的是三个不同的执行宿主——Python 解释器、Node 内核、浏览器引擎。本节要证明:同一份叙事规格在三个宿主上推进出的状态摘要逐位相同,而宿主之间的实现差异全部落在不影响状态的地方。
13.2 同源不同宿主
「同源」不是一句口号,它要能被断言。做法是选一条确定性策略路径——「总是选第一个可用选项」——让三个宿主各走一遍:
| 侧 | 宿主 | 取什么 | 实现 |
|---|---|---|---|
| Python | 解释器 | canonical 串 + FNV-1a 摘要 | sim/ne_13_2_hosts.py |
| TypeScript | Node 内核 | 重放同一路径 | core/test/ne_13_2_hosts.test.ts |
| 浏览器 | Chromium 内联 | 点击首个 [data-choice-id] | web/verify_ne13.mjs |
三侧若同源,则终局状态的 canonical 串与摘要必须逐位相同。这条策略不含随机:每次都取第一个可用选项,同一条路径每次跑都一样——于是「浏览器 vs Python」的比对里,唯一的变量就是宿主。
S 层:Python 宿主
脚本复用第 11 章的 sim/ne_11_common.py(walk_path / canonical / fnv1a),走完这条策略路径,落 out/ne_13_2_py.json 供另两侧比对。
#!/usr/bin/env python3
# file: sim/ne_13_2_hosts.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_13_2_hosts.py
"""卷二 13.2 同源不同宿主(S 层,Python 宿主)。
同一条确定性策略路径(「总是选第一个可用选项」,共 25 个事件)在三个宿主上推进:
Python(本脚本)/ Node 内核(core/test/ne_13_2_hosts.test.ts)/ 浏览器内联(web/verify_ne13.mjs)。
三侧必须给出同一个终局节点、结局、canonical 串与 FNV-1a 摘要。
实测锚点:终局节点 e_lit、结局 灯守、摘要 4162796975、事件数 25。
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ne_11_common import OUT, canonical, fnv1a, load_spec, walk_path # noqa: E402
JSON_PATH = os.path.join(OUT, "ne_13_2_py.json")
def main() -> int:
spec = load_spec()
choices, states = walk_path(spec)
s = states[-1]
ending = next(n for n in spec["dialogue"] if n["id"] == s["node"]).get("ending", "")
row = {"host": "python", "steps": len(choices), "node": s["node"], "ending": ending,
"canonical": canonical(s), "digest": fnv1a(canonical(s).encode("utf-8"))}
print(f"spec={spec['meta']['title']} 策略=总是选第一个可用选项(确定性,不含随机)")
print("宿主 事件数 终局节点 结局 摘要")
print(f"{row['host']:<11} {row['steps']:>5} {row['node']:<8} {row['ending']:<5} {row['digest']:>10}")
print(f"canonical(Python 宿主):{row['canonical']}")
print("待比对:Node 宿主 core/test/ne_13_2_hosts.test.ts · 浏览器宿主 web/verify_ne13.mjs")
assert row["steps"] == 25 and row["node"] == "e_lit" and row["digest"] == 4162796975, "锚点不符"
os.makedirs(OUT, exist_ok=True)
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump(row, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/ne_13_2_py.json")
print("OK ne_13_2_hosts:Python 宿主锚点 4162796975 已落盘,待 Node 与浏览器侧比对")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_13_2_hosts.py:
spec=雾港夜航 策略=总是选第一个可用选项(确定性,不含随机)
宿主 事件数 终局节点 结局 摘要
python 25 e_lit 灯守 4162796975
canonical(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
待比对:Node 宿主 core/test/ne_13_2_hosts.test.ts · 浏览器宿主 web/verify_ne13.mjs
wrote out/ne_13_2_py.json
OK ne_13_2_hosts:Python 宿主锚点 4162796975 已落盘,待 Node 与浏览器侧比对
P 层:Node 宿主与宿主差异清单
TS 侧用同一份 engine.ts 走同一条路径,断言锚点;并用五条测试逐条钉住「宿主差异清单」里每一类差异为什么不影响状态摘要。
// file: core/test/ne_13_2_hosts.test.ts
// tier: P
// verify: node --test 'core/test/ne_13_2_hosts.test.ts'
// 卷二 13.2:Node 宿主与 Python 宿主同源(25 事件 / e_lit / 灯守 / 4162796975),
// 并逐条验证「宿主差异清单」里每一条为什么不影响状态摘要。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { applyChoice, canonical, digest, initState } from "../src/inkwell/engine.ts";
import type { Spec, State } from "../src/inkwell/engine.ts";
import { digestFloat, firstPath, hostSummary } from "../src/inkwell/ne_13_2_hosts.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8")) as Spec;
const py = JSON.parse(readFileSync(join(ROOT, "out/ne_13_2_py.json"), "utf8")) as {
host: string; steps: number; node: string; ending: string; canonical: string; digest: number;
};
function finalState(): State {
const seq = firstPath(spec);
let s = initState(spec);
for (const c of seq) s = applyChoice(spec, s, c);
return s;
}
test("Node 宿主与 Python 宿主同源:25 事件 / e_lit / 灯守 / 摘要 4162796975", () => {
const s = hostSummary(spec);
assert.equal(s.steps, 25);
assert.equal(s.node, "e_lit");
assert.equal(s.ending, "灯守");
assert.equal(s.digest, 4162796975);
assert.equal(s.digest, py.digest);
assert.equal(s.canonical, py.canonical);
console.log(`Node 宿主:${s.steps} 事件 · ${s.node} · ${s.ending} · 摘要 ${s.digest}`);
});
test("宿主差异 · 溢出语义:浮点乘法偏离,Math.imul 与 Python 大整数一致", () => {
const st = finalState();
assert.equal(digest(st), 4162796975);
assert.notEqual(digestFloat(st), digest(st));
console.log(`Math.imul ${digest(st)} vs 浮点乘法 ${digestFloat(st)}`);
});
test("宿主差异 · 对象键序:canonical 按键名排序,插入顺序不影响摘要", () => {
const st = finalState();
const reversed: State = { ...st, aff: Object.fromEntries(Object.entries(st.aff).reverse()) };
assert.equal(canonical(reversed), canonical(st));
assert.equal(digest(reversed), digest(st));
console.log(`键序重排后 canonical 相同:${canonical(reversed) === canonical(st)}`);
});
test("宿主差异 · 整数运算与取模:Lehmer MINSTD 前两步两侧同值", () => {
const a = (1 * 48271) % 2147483647;
const b = (a * 48271) % 2147483647;
assert.deepEqual([a, b], [48271, (48271 * 48271) % 2147483647]);
assert.ok(a >= 0 && b >= 0);
console.log(`MINSTD 前两步 ${a} / ${b}(非负整数,无浮点)`);
});
test("宿主差异 · Unicode 归一化:摘要输入是纯 ASCII,无 NFC/NFD 歧义", () => {
const st = finalState();
assert.match(canonical(st), /^[\x20-\x7e]*$/);
console.log(`canonical 全 ASCII:${/^[\x20-\x7e]*$/.test(canonical(st))}`);
});
本机实跑 node --test 'core/test/ne_13_2_hosts.test.ts':
Node 宿主:25 事件 · e_lit · 灯守 · 摘要 4162796975
Math.imul 4162796975 vs 浮点乘法 3641085448
键序重排后 canonical 相同:true
MINSTD 前两步 48271 / 182605794(非负整数,无浮点)
canonical 全 ASCII:true
✔ Node 宿主与 Python 宿主同源:25 事件 / e_lit / 灯守 / 摘要 4162796975 (1.396875ms)
✔ 宿主差异 · 溢出语义:浮点乘法偏离,Math.imul 与 Python 大整数一致 (0.403125ms)
✔ 宿主差异 · 对象键序:canonical 按键名排序,插入顺序不影响摘要 (0.220083ms)
✔ 宿主差异 · 整数运算与取模:Lehmer MINSTD 前两步两侧同值 (0.513666ms)
✔ 宿主差异 · Unicode 归一化:摘要输入是纯 ASCII,无 NFC/NFD 歧义 (0.275667ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
W 层:浏览器宿主
web/verify_ne13.mjs 用 Playwright 无头打开 9.1 内联的原型页,重置到起点,循环「读首个 data-choice-id → 点击」直到到达结局,读出终局 canonical 与摘要,与 Python 宿主逐位比对;并在浏览器里用浮点乘法重算一遍摘要,演示宿主溢出语义。
// file: web/verify_ne13.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne13.mjs
// 卷二 13.2:浏览器宿主的同源验证。Playwright 无头打开 dist/ne09.html(内联同一份
// engine.ts),走「总是选第一个可用选项」的确定性路径(25 事件),把终局 canonical 与
// FNV-1a 摘要与 Python 宿主(out/ne_13_2_py.json)逐位比对;并在浏览器里用浮点乘法
// 算一遍摘要,演示「宿主差异 · 溢出语义」——浮点值必然偏离。NODE_PATH 对 ESM 无效,
// 必须 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_13_2_py.json"), "utf8"));
const url = pathToFileURL(join(HERE, "dist/ne09.html")).href;
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: 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();
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("策略路径事件数(浏览器 = Python)", steps, py.steps);
check("终局节点", got.node, py.node);
check("结局", got.ending, py.ending);
check("canonical(浏览器 vs Python)", got.canonical, py.canonical);
check("FNV-1a 摘要(浏览器 vs Python)", got.digest, py.digest);
check("摘要锚点 4162796975", got.digest, 4162796975);
// 宿主差异 · 溢出语义:浏览器里用普通浮点乘法重算一遍,必然偏离
const naive = await page.evaluate(() => {
const str = window.__ne09.canonical;
let h = 2166136261;
for (let i = 0; i < str.length; i += 1) {
h = (h ^ str.charCodeAt(i)) >>> 0;
h = (h * 16777619) % 4294967296;
}
return h >>> 0;
});
check("宿主差异 · 浮点乘法偏离 Math.imul", naive !== 4162796975, true);
console.log(` 浮点乘法 ${naive} vs Math.imul ${got.digest}`);
const shot = await page.screenshot({ path: join(SHOTS, "ne13_host_parity.png"), fullPage: true });
console.log(`OK 截图 out/shots/ne13_host_parity.png ${shot.length} bytes`);
if (shot.length <= 0) { fail += 1; console.log("FAIL 截图为空"); }
await browser.close();
console.log(`== verify_ne13: 失败 ${fail}`);
process.exit(fail ? 1 : 0);
本机实跑 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne13.mjs(canonical 一行已折行):
OK 策略路径事件数(浏览器 = Python): 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_dq_2>e_lit
OK FNV-1a 摘要(浏览器 vs Python): 4162796975
OK 摘要锚点 4162796975: 4162796975
OK 宿主差异 · 浮点乘法偏离 Math.imul: true
浮点乘法 3641085448 vs Math.imul 4162796975
OK 截图 out/shots/ne13_host_parity.png 55002 bytes
== verify_ne13: 失败 0
宿主差异清单
三个宿主实现细节不同,但差异全部落在不影响状态的地方。逐条说明:
| # | 宿主差异 | 具体不同 | 为什么状态摘要不受影响 |
|---|---|---|---|
| ① | 整数运算 | Python int 任意精度;JS number 双精度 | 状态里所有整数 ≤ 100、计数 ≤ 数十,远在 2⁵³ 内,双精度可精确表示 |
| ② | 取模 | Python % 与 JS % 对正操作数同结果 | MINSTD 状态与模数 2147483647 恒为正,取模无负数语义分歧 |
| ③ | 32 位乘法 | Python 大整数 & 0xFFFFFFFF;JS 必须 Math.imul | 两侧都在乘法后截到低 32 位;若 JS 用浮点乘法即偏离(实测 3641085448 ≠ 4162796975) |
| ④ | 对象键序 | Python dict 与 JS object 插入序不同 | canonical 显式按键名排序,重排键后摘要不变 |
| ⑤ | Unicode 归一化 | NFC / NFD 可能产生不同码点序列 | 摘要输入全是 ASCII 标识符与整数,没有可归一化的字符 |
⑤ 是本套能把摘要做成判据的根本原因:canonical 串里没有一个字来自台词。台词文本涉及编码与换行,跨宿主把它当判据是不可靠的;状态是一串确定的 ASCII,可以稳定地哈希。
判读:三侧同值 4162796975
| 侧 | 宿主 | 事件数 | 终局节点 | 结局 | 摘要 |
|---|---|---|---|---|---|
| Python | 解释器 | 25 | e_lit | 灯守 | 4162796975 |
| TypeScript | Node 内核 | 25 | e_lit | 灯守 | 4162796975 |
| 浏览器 | Chromium 内联 | 25 | e_lit | 灯守 | 4162796975 |
三侧给同一个值。「浮点乘法 3641085448」 是反证:只要宿主在 32 位乘法上不用 Math.imul,摘要立刻偏离——这说明三侧一致不是「怎么算都差不多」,而是溢出语义被显式对齐的结果。
自检表
| 检查项 | 合格标准 |
|---|---|
| 同一条路径 | 三侧都走「总是选第一个可用选项」,共 25 个事件 |
| canonical 逐位一致 | 浏览器规范串 = Python 侧产物规范串 |
| 摘要逐位一致 | 三侧摘要均为 4162796975 |
| 终局一致 | 节点 e_lit、结局 灯守 |
| 差异可解释 | 五类宿主差异逐条说明为何不影响摘要 |
| 口径纪律 | 只对整数状态写「逐位一致」 |
三侧一致解决了「同一批输入给同一批输出」。但真实工程里更常见的是偏差:某一侧悄悄改了钳位上限、改了阈值,摘要就会变。下一节把「什么必须相同、什么允许不同」写死成一张容差表。
阅读导航:上一节:13.1 跨语言摘要与 digest · 下一节:13.3 偏差与容差 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。