16.3 交付卷三

卷二交付给卷三《叙事体验与路径度量》的五类接口:规格段、内核函数、仿真产物、原型产物、判据与容差。本节用自检脚本逐项核对磁盘上的段、函数、产物与阈值,再用 W 层脚本在浏览器里复算 16.2 的锚点摘要,并声明边界——本卷只交结构量,体验度量归卷三。

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节点 / 边 / 进入条件场景层路径统计
dialogue48 节点 / 115 选项选择密度与路径编码
conditions条件文法声明假选择检测的求值规范
state18 flag / 4 变量 / 9 物品状态编码与摘要口径
endings5 结局与守卫条件结局分布与熵
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) → intFNV-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_permille850下限1000‰
ending_entropy_min_milli_bits1500下限归卷三
affinity_mae_max12上限归卷三
tension_mae_max1上限0(×1000)

四个阈值里,本卷只对覆盖率与张力残差给了实测;结局分布熵与好感度曲线 MAE 是卷三的度量件。判据跟着规格走,不跟着实现走——所以阈值在本卷就已经写死在 metrics 里,谁来实现都按同一组数验收。

边界声明

  • 本卷只交结构量:可达状态、边覆盖、路径摘要、张力残差——都是能从规格与路径直接算出来的量。
  • 本卷不做体验度量:情感曲线、结局分布熵、曲线形状分类、路径热力图,一律归卷三。
  • 不重做上游的三件事:重写条件文法、重算穷举、重新定义摘要口径——这三件都是地基,动它们会同时破坏三处消费的一致性。
  • 不改十段的字段语义:卷三可以新增旁挂产物(曲线表、分布表),但 story_spec.json 十段的字段含义由前两卷冻结。
  • 不动种子与 PRNG:20261016 与 Lehmer MINSTD 是跨语言采样的共同前提,改了就复现不出同一批路径。
  • 代理指标不作绝对断言:覆盖率、熵、MAE 只用于相对比较,不得读成体验更好或提升多少倍。

常见误解

  • 「交付 = 复制文件」:文件复制过去没有用,接口不清楚,卷三还是要猜。
  • 「原型只是演示」:原型是 W 层的执行宿主,卷三要在它上面跑批量路径断言。
  • 「产物是缓存,可以丢」:产物是基准,丢了就没有回归对照,改动无法判定是否退化。
  • 「函数越多越完整」:交付的是最小充分集,多给会锁死扩展空间。
  • 「度量可以顺手做掉」:体验度量需要目标曲线与代理指标口径,顺手做的数字没有可比性。

卷二的收口

三节下来,卷二做了三件事:把零件装成检查表(16.1 的十二项断言证明引擎自洽)、把三处消费串成联调(16.2 的摘要逐位相同证明引擎与规格同源)、把接口交给下一卷(16.3 的清单与原型验收证明交付可复核)。卷三在这份交付上做度量,卷四(若有)在度量上做决策——三卷共用一份真源、一个内核、一组判据,这就是「一份叙事规格,三处消费」在引擎侧的完整含义。

阅读导航:上一节:16.2 三处消费的联调 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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