13.2 同源不同宿主

同一条确定性策略路径(总是选第一个可用选项,25 个事件)在 Python、Node 内核与浏览器内联三个宿主上推进,终局节点 e_lit、结局 灯守、FNV-1a 摘要 4162796975 三侧逐位相同。本节给出宿主差异清单:整数运算、取模、Math.imul 与大整数、对象键序、Unicode 归一化,逐条说明为何不影响状态摘要。

《游戏设计》卷一《玩法机制与可执行规格》 的「四处消费」都在同一类运行环境里,比对的是数值;本章的「三处消费」跨的是三个不同的执行宿主——Python 解释器、Node 内核、浏览器引擎。本节要证明:同一份叙事规格在三个宿主上推进出的状态摘要逐位相同,而宿主之间的实现差异全部落在不影响状态的地方。

13.2 同源不同宿主

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

侧宿主取什么实现
Python解释器canonical 串 + FNV-1a 摘要sim/ne_13_2_hosts.py
TypeScriptNode 内核重放同一路径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解释器25e_lit灯守4162796975
TypeScriptNode 内核25e_lit灯守4162796975
浏览器Chromium 内联25e_lit灯守4162796975

三侧给同一个值。「浮点乘法 3641085448」 是反证:只要宿主在 32 位乘法上不用 Math.imul,摘要立刻偏离——这说明三侧一致不是「怎么算都差不多」,而是溢出语义被显式对齐的结果。

自检表

检查项合格标准
同一条路径三侧都走「总是选第一个可用选项」,共 25 个事件
canonical 逐位一致浏览器规范串 = Python 侧产物规范串
摘要逐位一致三侧摘要均为 4162796975
终局一致节点 e_lit、结局 灯守
差异可解释五类宿主差异逐条说明为何不影响摘要
口径纪律只对整数状态写「逐位一致」

三侧一致解决了「同一批输入给同一批输出」。但真实工程里更常见的是偏差:某一侧悄悄改了钳位上限、改了阈值,摘要就会变。下一节把「什么必须相同、什么允许不同」写死成一张容差表。

阅读导航:上一节:13.1 跨语言摘要与 digest · 下一节:13.3 偏差与容差 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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