16.3 交付卷三
Phaser 里的分支叙事实现 把分支叙事跑成了一个能玩的页面;本卷做的是把这件事做成引擎:一份规格、一个确定性的状态内核、一套可复算的产物、两个自包含的原型。卷二交付的是执行,卷三《叙事体验与路径度量》接住的是度量。与卷一交付给卷二时对照,两卷交的东西形状相同、内容不同:卷一交的是「规格自洽 + 三处消费同源」,卷二交的是「引擎与规格同源 + 原型可离线复算」。
交付不是把文件打包过去,而是把接口说清楚:卷三要读哪些段、在哪些函数上扩展、拿哪些产物当回归基准、按什么判据验收、以及本卷没有做什么。
交付清单:五类接口
脚本读规格与产物,逐项在磁盘上核对——段在顶层键里、函数在 engine.ts 里、产物在 out/ 下、原型在 web/dist/ 下、判据在 metrics 里,缺一即非零退出。
#!/usr/bin/env python3
# file: sim/ne_16_3_handoff.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_16_3_handoff.py
"""卷二 16.3 交付卷三:把交给卷三《叙事体验与路径度量》的接口整理成清单并逐项自检。
交付五类:规格段 / 内核函数 / 仿真产物 / 原型产物 / 判据与容差。每一项都要在
磁盘上找得到 —— 规格段在 story_spec.json 顶层键里、函数在 engine.ts 的
`export function` 里、产物在 out/ 下、原型在 web/dist/ 下、判据在 spec.metrics 里。
缺一即 FAIL 并非零退出。本卷只交**结构量**;体验类度量(情感曲线、结局分布熵、
张力曲线)是卷三的事,本脚本不产出、不引用卷三的任何 slug。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SEGMENTS = ["meta", "world", "characters", "beats", "scene_graph",
"dialogue", "conditions", "state", "endings", "metrics"]
FUNCS = ["clamp", "initState", "nodeById", "condOk", "applyEffects", "applyChoice",
"canonical", "digest", "replay", "samplePaths"]
ARTIFACTS = ["out/ne_16_1_assembly.json", "out/ne_16_2_integration.json",
"out/ne_05_2_reachable.json", "out/ne_01_3_py.json"]
PROTOTYPES = ["web/dist/ne09.html", "web/dist/ne10.html"]
CRITERIA = ["coverage_target_permille", "ending_entropy_min_milli_bits",
"affinity_mae_max", "tension_mae_max"]
def main() -> int:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
spec = json.load(fh)
engine = open(os.path.join(ROOT, "core/src/inkwell/engine.ts"), encoding="utf-8").read()
fails: list[str] = []
seg_ok = [s for s in SEGMENTS if s in spec]
fn_ok = [f for f in FUNCS if f"export function {f}(" in engine]
art_ok = [a for a in ARTIFACTS if os.path.exists(os.path.join(ROOT, a))]
pro_ok = [p for p in PROTOTYPES if os.path.exists(os.path.join(ROOT, p))]
crit_ok = [c for c in CRITERIA if c in spec["metrics"]]
for name, got, want in (("规格段", seg_ok, SEGMENTS), ("内核函数", fn_ok, FUNCS),
("仿真产物", art_ok, ARTIFACTS), ("原型产物", pro_ok, PROTOTYPES),
("判据容差", crit_ok, CRITERIA)):
miss = [x for x in want if x not in got]
if miss:
fails.append(f"{name}缺 {miss}")
print("交付清单(卷二《叙事引擎与分支系统》→ 卷三《叙事体验与路径度量》)")
print(f"① 规格段 config/story_spec.json {len(seg_ok)}/{len(SEGMENTS)}")
print(f"② 内核函数 core/src/inkwell/engine.ts {len(fn_ok)}/{len(FUNCS)}")
print(" " + "、".join(fn_ok))
print(f"③ 仿真产物 {len(art_ok)}/{len(ARTIFACTS)}")
for a in art_ok:
print(f" {a:<32} {os.path.getsize(os.path.join(ROOT, a))} B")
print(f"④ 原型产物 {len(pro_ok)}/{len(PROTOTYPES)}")
for p in pro_ok:
print(f" {p:<32} {os.path.getsize(os.path.join(ROOT, p))} B")
print(f"⑤ 判据容差 spec.metrics {len(crit_ok)}/{len(CRITERIA)}")
for c in crit_ok:
print(f" {c:<32} {spec['metrics'][c]}")
print("给卷三的接口:规格段(只读真源)+ 内核函数(重放 / 摘要)+ 产物(基线)+ 判据(契约)")
print("本卷没做的:体验度量 / 情感曲线 / 结局分布熵 / 张力曲线 —— 归卷三")
total_ok = len(seg_ok) + len(fn_ok) + len(art_ok) + len(pro_ok) + len(crit_ok)
total_want = len(SEGMENTS) + len(FUNCS) + len(ARTIFACTS) + len(PROTOTYPES) + len(CRITERIA)
print(f"交付自检 {'PASS' if not fails else 'FAIL'}({total_ok}/{total_want})")
for f in fails:
print(f" FAIL {f}")
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/ne_16_3_handoff.py:
交付清单(卷二《叙事引擎与分支系统》→ 卷三《叙事体验与路径度量》)
① 规格段 config/story_spec.json 10/10
② 内核函数 core/src/inkwell/engine.ts 10/10
clamp、initState、nodeById、condOk、applyEffects、applyChoice、canonical、digest、replay、samplePaths
③ 仿真产物 4/4
out/ne_16_1_assembly.json 1393 B
out/ne_16_2_integration.json 1712 B
out/ne_05_2_reachable.json 3112 B
out/ne_01_3_py.json 2999 B
④ 原型产物 2/2
web/dist/ne09.html 41641 B
web/dist/ne10.html 41406 B
⑤ 判据容差 spec.metrics 4/4
coverage_target_permille 850
ending_entropy_min_milli_bits 1500
affinity_mae_max 12
tension_mae_max 1
给卷三的接口:规格段(只读真源)+ 内核函数(重放 / 摘要)+ 产物(基线)+ 判据(契约)
本卷没做的:体验度量 / 情感曲线 / 结局分布熵 / 张力曲线 —— 归卷三
交付自检 PASS(30/30)
五类全齐,30/30。清单脚本把「交付不合格」变成非零退出——它不会安静地过去。
W 层:原型交付的浏览器验收
清单只能证明产物在磁盘上。原型是给卷三当回归基准用的,所以还要证明它在浏览器里真的能跑,并且复算出 16.2 的同一个锚点摘要。脚本读 16.2 的产物,把选择序列逐条点进页面,再比对三个宿主:
// file: web/verify_ne16.mjs
// tier: W
// verify: node web/build_ne10.mjs && NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne16.mjs
// 卷二 16.2 三宿主联调:Python 产物 / TS 内核 / 浏览器页面在同一条锚点路径上给出同一个摘要。
// 复用 web/build_ne10.mjs 产出的 dist/ne10.html(只读,不改);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";
import { replay, digest } from "../core/src/inkwell/engine.ts";
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 spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8"));
const py = JSON.parse(readFileSync(join(ROOT, "out/ne_16_2_integration.json"), "utf8"));
const url = pathToFileURL(join(HERE, "dist/ne10.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: 1000 } });
// 冷启动预热页:首个 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();
const page = await ctx.newPage();
await page.goto(url);
await page.evaluate(() => window.__ne10.reset());
for (const c of py.choices) await page.click(`[data-choice-id="${c}"]`);
const got = await page.evaluate(() => ({
node: window.__ne10.node, ending: window.__ne10.ending, digest: window.__ne10.digest,
}));
check("TS 内核摘要 == Python 产物", digest(replay(spec, py.choices)), py.digest);
check("浏览器摘要 == Python 产物", got.digest, py.digest);
check("浏览器终局节点 == Python 产物", got.node, py.node);
check("浏览器结局 == Python 产物", got.ending, py.ending);
await page.goto(url);
await page.evaluate(() => window.__ne10.reset());
for (const c of py.choices) await page.click(`[data-choice-id="${c}"]`);
check("两遍渲染摘要一致", await page.evaluate(() => window.__ne10.digest), py.digest);
const shot = await page.screenshot({ path: join(SHOTS, "ne16_three_host.png"), fullPage: true });
console.log(`OK 截图 out/shots/ne16_three_host.png ${shot.length} bytes`);
if (shot.length < 1) { fail += 1; console.log("FAIL 截图为空"); }
await browser.close();
console.log(`== verify_ne16: 三宿主摘要 ${got.digest},失败 ${fail}`);
process.exit(fail ? 1 : 0);
本机实跑 node web/build_ne10.mjs && NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ne16.mjs:
wrote web/dist/ne10.html 41406 bytes
(node:38468) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
OK TS 内核摘要 == Python 产物: 4162796975
OK 浏览器摘要 == Python 产物: 4162796975
OK 浏览器终局节点 == Python 产物: e_lit
OK 浏览器结局 == Python 产物: 灯守
OK 两遍渲染摘要一致: 4162796975
OK 截图 out/shots/ne16_three_host.png 43536 bytes
== verify_ne16: 三宿主摘要 4162796975,失败 0
六个判定全过:Python、TypeScript、浏览器三个宿主在同一条 25 步路径上给出同一个整数摘要 4162796975,两遍渲染结果一致,截图非空。前两行是 Node 直接加载 .ts 时的预期告警,不影响判定。
为什么是五类,而不是一整套 API
| 类 | 性质 | 缺了会怎样 |
|---|---|---|
| 规格段 | 数据 | 卷三没有真源可读 |
| 内核函数 | 代码 | 卷三得重写重放与摘要 |
| 仿真产物 | 基准 | 卷三没有回归对照 |
| 原型产物 | 可玩宿主 | 卷三没有 W 层的批量执行环境 |
| 判据与容差 | 契约 | 验收没有可断言的边界 |
数据、代码、基准、宿主、契约——五类缺一,卷三都接不住。反过来,多给反而有害:把实现细节也一并交过去,卷三就会被锁死在卷二的内部结构上。接口要刚好够用。
规格段:卷三读哪些、为什么
| 段 | 内容 | 卷三怎么用 |
|---|---|---|
meta | 标题 / 幕数 / start / scale | 起点与规模口径 |
world | 派系 / 地点 / lore / rules | 情境一致性度量的输入 |
characters | 目标 / 恐惧 / 秘密 / affinity | 关系曲线的初值 |
beats | 节拍 / 幕 / 场景 / tension | 张力曲线的输入 |
scene_graph | 节点 / 边 / 进入条件 | 场景层路径统计 |
dialogue | 48 节点 / 115 选项 | 选择密度与路径编码 |
conditions | 条件文法声明 | 假选择检测的求值规范 |
state | 18 flag / 4 变量 / 9 物品 | 状态编码与摘要口径 |
endings | 5 结局与守卫条件 | 结局分布与熵 |
metrics | 曲线 / 覆盖率 / 熵 / MAE 上限 | 回归判据 |
meta 与 metrics 两段容易被当成附属信息,其实它们承担了卷三最需要的两个口径:从哪里开始(start)与到什么程度算合格(metrics 里的四个阈值)。
内核函数:卷三在什么之上扩展
| 函数 | 签名要点 | 卷三在它之上做什么 |
|---|---|---|
clamp | (v) → int | 曲线取值域的统一夹取 |
initState | (spec) → State | 轨迹采样的初始快照 |
nodeById | (spec, id) → Node | 节点级度量的取数 |
condOk | (cond, state) → bool | 假选择检测的判定 |
applyEffects | (effects, state) → State | 差量分析 |
applyChoice | (spec, state, cid) → State | 门禁与状态推进 |
canonical | (state) → string | 路径编码的规范串 |
digest | (state) → int | FNV-1a 32 位,只对状态 |
replay | (spec, choices) → State | 轨迹复现 |
samplePaths | (spec, n, seed) → string[][] | 采样策略与分布 |
这十个函数是卷三的原点,不是它的上限。卷三会往上加曲线、分布、热力图这些度量件,但不改这十个的语义——一改,跨语言摘要就不再逐位相同,前两卷的地基随之失效。
仿真产物:作为回归基准
| 产物 | 内容 | 卷三怎么用 |
|---|---|---|
ne_16_1_assembly.json | 十二项检查结果与锚点摘要 | 装配回归基准 |
ne_16_2_integration.json | 锚点路径 / 摘要 / 覆盖率 / 分布 | 联调基准 |
ne_05_2_reachable.json | 完整可达状态的逐层累计 | 状态空间基线 |
ne_01_3_py.json | 四种确定性策略的摘要 | 跨语言对照 |
四个产物都是可重算的:只要规格不变、种子不变,重跑一次就得到同一份内容。这正是它们能当基准的原因。
判据与容差
| 判据 | 值 | 类型 | 本卷实测 |
|---|---|---|---|
coverage_target_permille | 850 | 下限 | 1000‰ |
ending_entropy_min_milli_bits | 1500 | 下限 | 归卷三 |
affinity_mae_max | 12 | 上限 | 归卷三 |
tension_mae_max | 1 | 上限 | 0(×1000) |
四个阈值里,本卷只对覆盖率与张力残差给了实测;结局分布熵与好感度曲线 MAE 是卷三的度量件。判据跟着规格走,不跟着实现走——所以阈值在本卷就已经写死在 metrics 里,谁来实现都按同一组数验收。
边界声明
- 本卷只交结构量:可达状态、边覆盖、路径摘要、张力残差——都是能从规格与路径直接算出来的量。
- 本卷不做体验度量:情感曲线、结局分布熵、曲线形状分类、路径热力图,一律归卷三。
- 不重做上游的三件事:重写条件文法、重算穷举、重新定义摘要口径——这三件都是地基,动它们会同时破坏三处消费的一致性。
- 不改十段的字段语义:卷三可以新增旁挂产物(曲线表、分布表),但
story_spec.json十段的字段含义由前两卷冻结。 - 不动种子与 PRNG:
20261016与 Lehmer MINSTD 是跨语言采样的共同前提,改了就复现不出同一批路径。 - 代理指标不作绝对断言:覆盖率、熵、MAE 只用于相对比较,不得读成体验更好或提升多少倍。
常见误解
- 「交付 = 复制文件」:文件复制过去没有用,接口不清楚,卷三还是要猜。
- 「原型只是演示」:原型是 W 层的执行宿主,卷三要在它上面跑批量路径断言。
- 「产物是缓存,可以丢」:产物是基准,丢了就没有回归对照,改动无法判定是否退化。
- 「函数越多越完整」:交付的是最小充分集,多给会锁死扩展空间。
- 「度量可以顺手做掉」:体验度量需要目标曲线与代理指标口径,顺手做的数字没有可比性。
卷二的收口
三节下来,卷二做了三件事:把零件装成检查表(16.1 的十二项断言证明引擎自洽)、把三处消费串成联调(16.2 的摘要逐位相同证明引擎与规格同源)、把接口交给下一卷(16.3 的清单与原型验收证明交付可复核)。卷三在这份交付上做度量,卷四(若有)在度量上做决策——三卷共用一份真源、一个内核、一组判据,这就是「一份叙事规格,三处消费」在引擎侧的完整含义。
阅读导航:上一节:16.2 三处消费的联调 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。