16.3 交付与后续
独立游戏的叙事设计与分支对话 交的是散文经验——分支怎么写、好感度怎么调,代码不可跑;本卷交的是可算的度量:一份真源、一个确定性内核、一组可复算的产物、六项代理指标的口径。三卷收官,本节把交付什么、怎么接手、不做什么讲清楚。交付不是把文件打包过去,而是把接口说清楚:接手的人要读哪些段、跑哪些命令、拿哪些产物当回归基准、按什么判据验收,以及本卷没有做什么。
交付清单
四类东西:真源与内核(数据 + 代码)、度量工具链(S / P / W / B 九件)、可复算产物(out/ 下的 JSON)、口径声明(六项代理指标的定义与阈值)。每一项都要在磁盘上找得到,缺一即 FAIL 并非零退出:
#!/usr/bin/env python3
# file: sim/nm_16_3_deliverable.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_16_3_deliverable.py
"""卷三 16.3 交付与后续(S 层):把本卷的交付物整理成清单并逐项在磁盘上自检。
三卷收官,交付的是四类东西:**真源与内核**(数据 + 代码)、**度量工具链**(S / P / W 三层
脚本)、**可复算产物**(out/ 下的 JSON)、**口径声明**(六项代理指标的定义与阈值)。
每一项都要在磁盘上找得到,缺一即 FAIL 并非零退出。清单同时给出「接手五步」与
「本套不做的事」——后者逐处标 X(需真实业务环境,命令行不可复现)。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
FOUNDATION = [
("真源", "config/story_spec.json"),
("内核", "core/src/inkwell/engine.ts"),
("打包器", "web/pack.mjs"),
]
TOOLCHAIN = [
("S", "sim/nm_16_common.py"),
("S", "sim/nm_16_1_checklist.py"),
("S", "sim/nm_16_2_fullchain.py"),
("S", "sim/nm_16_3_deliverable.py"),
("P", "core/src/inkwell/nm_16_2_fullchain.ts"),
("P", "core/test/nm_16_2_fullchain.test.ts"),
("W", "web/build_nm16.mjs"),
("W", "web/verify_nm16.mjs"),
("B", "sim/nm_16_3_perturb.py"),
]
ARTIFACTS = [
("S", "out/nm_16_1_checklist.json"),
("S", "out/nm_16_2_fullchain.json"),
("W", "out/nm_16_2_w_fullchain.json"),
("B", "out/nm_16_3_perturb.json"),
]
VOL2_INTERFACE = [
("联调", "out/ne_16_2_integration.json"),
("可达", "out/ne_05_2_reachable.json"),
("锚点", "out/ne_01_3_py.json"),
]
SIX_METRICS = [
("分支覆盖率", "走过的去重对话边 × 1000 // 去重边总数", "‰", "≥ 850"),
("结局分布熵", "-Σ p·log2(p) × 1000 取整", "毫比特", "≥ 1500"),
("张力曲线 MAE", "Σ|实际 tension - 目标| × 1000 // 节拍数", "毫", "≤ 1000"),
("好感度曲线 MAE", "Σ|夹取后 - 未夹取| × 1000 // 样本数", "毫", "≤ 12000"),
("一致性残差", "声明-行为失配计数(D + K + S + R)", "计数", "proxy,只做相对比较"),
("整数不变量残差", "好感度越界 + tension 越界计数", "计数", "恒为 0"),
]
NOT_DONE = [
("真人玩家测试", "需真实业务环境,命令行不可复现"),
("真实译文与配音成本", "需真实业务环境,命令行不可复现"),
("真实团队评审流程", "需真实业务环境,命令行不可复现"),
("叙事文本创作质量", "划界到《孢子三部曲创作手记》"),
]
HANDOVER = [
"读 config/story_spec.json,用 engine.ts 的 initState 建初始状态,确认起点 meta.start。",
"跑 sim/nm_16_1_checklist.py,17 条装配断言全 PASS 再往下。",
"跑 sim/nm_16_2_fullchain.py 生成 out/nm_16_2_fullchain.json(含锚点摘要与包含关系)。",
"跑 node --test 'core/test/nm_16_2_fullchain.test.ts' 与 web/verify_nm16.mjs,三处摘要逐位相同。",
"只有在以上全绿之后,才改规格或节拍标注——改一处要同时重跑三处消费。",
]
def main() -> int:
spec = json.load(open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8"))
m = spec["metrics"]
def exist(rel):
return os.path.exists(os.path.join(ROOT, rel))
groups = [("真源与内核", FOUNDATION), ("度量工具链", TOOLCHAIN),
("可复算产物", ARTIFACTS), ("卷二接口", VOL2_INTERFACE)]
fails = []
print("交付清单(卷三《叙事体验与路径度量》· 三卷收官)")
total_ok = total = 0
for name, items in groups:
ok = [rel for _, rel in items if exist(rel)]
total_ok += len(ok)
total += len(items)
print(f"· {name} {len(ok)}/{len(items)}")
for kind, rel in items:
mark = "OK " if exist(rel) else "MISS"
sz = os.path.getsize(os.path.join(ROOT, rel)) if exist(rel) else 0
print(f" {mark} [{kind}] {rel:<38} {sz} B")
miss = [rel for _, rel in items if not exist(rel)]
if miss:
fails.append(f"{name}缺 {miss}")
print(f"· 六项代理指标(口径声明) {len(SIX_METRICS)}/6")
for nm, formula, unit, thresh in SIX_METRICS:
print(f" {nm:<14} {formula:<34} 单位 {unit:<6} 判据 {thresh}")
print(f"· 阈值来源 config/story_spec.json#metrics 覆盖率 {m['coverage_target_permille']}‰ · "
f"熵 {m['ending_entropy_min_milli_bits']} · 好感度 MAE {m['affinity_mae_max']} · 张力 MAE {m['tension_mae_max']}")
print(f"· 本套不做(标 X) {len(NOT_DONE)} 项")
for what, why in NOT_DONE:
print(f" X {what:<16} {why}")
print(f"交付自检 {'PASS' if not fails else 'FAIL'}({total_ok}/{total})")
for f in fails:
print(f" FAIL {f}")
report = {"foundation": FOUNDATION, "toolchain": TOOLCHAIN, "artifacts": ARTIFACTS,
"vol2_interface": VOL2_INTERFACE,
"six_metrics": [{"name": n, "formula": f, "unit": u, "criterion": c}
for n, f, u, c in SIX_METRICS],
"not_done": [{"item": w, "why": y} for w, y in NOT_DONE],
"handover": HANDOVER, "checked": total, "present": total_ok,
"thresholds": {"coverage_target_permille": m["coverage_target_permille"],
"ending_entropy_min_milli_bits": m["ending_entropy_min_milli_bits"],
"affinity_mae_max": m["affinity_mae_max"],
"tension_mae_max": m["tension_mae_max"]}}
with open(os.path.join(ROOT, "out/nm_16_3_deliverable.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_16_3_deliverable.json")
print("OK nm_16_3_deliverable:四类交付物 + 六项指标口径 + 不做清单已落盘")
return 0 if not fails else 1
if __name__ == "__main__":
sys.exit(main())
$ cd /Users/bingrong.yan/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/nm_16_3_deliverable.py
交付清单(卷三《叙事体验与路径度量》· 三卷收官)
· 真源与内核 3/3
OK [真源] config/story_spec.json 38200 B
OK [内核] core/src/inkwell/engine.ts 6286 B
OK [打包器] web/pack.mjs 3663 B
· 度量工具链 9/9
OK [S] sim/nm_16_common.py 10873 B
OK [S] sim/nm_16_1_checklist.py 7714 B
OK [S] sim/nm_16_2_fullchain.py 4181 B
OK [S] sim/nm_16_3_deliverable.py 6099 B
OK [P] core/src/inkwell/nm_16_2_fullchain.ts 2586 B
OK [P] core/test/nm_16_2_fullchain.test.ts 3290 B
OK [W] web/build_nm16.mjs 4207 B
OK [W] web/verify_nm16.mjs 4238 B
OK [B] sim/nm_16_3_perturb.py 4204 B
· 可复算产物 4/4
OK [S] out/nm_16_1_checklist.json 2431 B
OK [S] out/nm_16_2_fullchain.json 1322 B
OK [W] out/nm_16_2_w_fullchain.json 334 B
OK [B] out/nm_16_3_perturb.json 1341 B
· 卷二接口 3/3
OK [联调] out/ne_16_2_integration.json 1711 B
OK [可达] out/ne_05_2_reachable.json 3112 B
OK [锚点] out/ne_01_3_py.json 2999 B
· 六项代理指标(口径声明) 6/6
分支覆盖率 走过的去重对话边 × 1000 // 去重边总数 单位 ‰ 判据 ≥ 850
结局分布熵 -Σ p·log2(p) × 1000 取整 单位 毫比特 判据 ≥ 1500
张力曲线 MAE Σ|实际 tension - 目标| × 1000 // 节拍数 单位 毫 判据 ≤ 1000
好感度曲线 MAE Σ|夹取后 - 未夹取| × 1000 // 样本数 单位 毫 判据 ≤ 12000
一致性残差 声明-行为失配计数(D + K + S + R) 单位 计数 判据 proxy,只做相对比较
整数不变量残差 好感度越界 + tension 越界计数 单位 计数 判据 恒为 0
· 阈值来源 config/story_spec.json#metrics 覆盖率 850‰ · 熵 1500 · 好感度 MAE 12 · 张力 MAE 1
· 本套不做(标 X) 4 项
X 真人玩家测试 需真实业务环境,命令行不可复现
X 真实译文与配音成本 需真实业务环境,命令行不可复现
X 真实团队评审流程 需真实业务环境,命令行不可复现
X 叙事文本创作质量 划界到《孢子三部曲创作手记》
交付自检 PASS(19/19)
wrote out/nm_16_3_deliverable.json
OK nm_16_3_deliverable:四类交付物 + 六项指标口径 + 不做清单已落盘
三卷怎么被人接手用
接手的人只要按顺序跑五步,就能把整套重新立起来:
- 读真源:
config/story_spec.json十段,用engine.ts的initState建初始状态,确认起点meta.start。 - 跑装配:
sim/nm_16_1_checklist.py,17 条断言全 PASS 再往下——装配不过就别谈度量。 - 跑联调:
sim/nm_16_2_fullchain.py生成out/nm_16_2_fullchain.json(锚点摘要 + 包含关系)。 - 跑 P / W:
node --test 'core/test/nm_16_2_fullchain.test.ts'与web/verify_nm16.mjs,三处摘要逐位相同。 - 才改规格:只有在以上全绿之后才改规格或节拍标注——改一处要同时重跑三处消费,否则三卷立刻不同源。接口也就三个:真源段(读什么)、内核函数(
initState/condOk/applyEffects/canonical/digest)、产物基准(拿哪个 JSON 当回归对照)。换人换机器,跑出来还是同一个4162796975。
哪些事本套不做
交付清单里那 4 条 X 不是谦虚,是划界:真人玩家测试需真实业务环境、命令行不可复现,本套量的是模拟路径的结构量,代理不了真人体验;真实译文与配音成本 / 真实团队评审流程同样需真实业务环境、命令行不可复现;叙事文本创作质量划界到《孢子三部曲创作手记》,本卷只量结构、不评文笔——四处均标 X。
六项指标全部是代理指标(proxy),只做相对比较:覆盖率 1000‰ 说的是边覆盖比例,不声称更好玩;结局熵 1714 毫比特是分布形状,不是「玩家惊喜度」;一致性残差 8 是失配计数,只能横向比。
后续可做的方向
这套东西是可扩展的,往下最自然的三个方向:
- 多规格回归:把
story_spec.json扩成一组变体,用 16.1 的检查表批量对账,看哪些规格会掉出阈值。 - 度量反演:13 章做过「参数 → 曲线」的反演,可以扩成「曲线 → 参数」的搜索,自动找能同时满足覆盖率与熵的节拍标注。
- 真人数据接入:一旦有真实玩家路径日志,把它喂进同一套采样器,代理指标与真实分布就能并排比——但这一步需真实业务环境,命令行不可复现,仍标 X。
B 层:交付口径的扰动扫描
交付的口径能不能扛住扰动?B 层扫两族:采样规模(覆盖率与熵随 N 收敛,小 N 必然低报)与节拍标注(张力曲线 MAE 随之抬起)。全程只发软告警、始终 exit 0——扰动不该让构建硬失败:
#!/usr/bin/env python3
# file: sim/nm_16_3_perturb.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_16_3_perturb.py
"""卷三 16.3 交付与后续(B 层):交付口径的扰动扫描——采样规模与节拍标注。
两族扰动,全部只产出**软告警**,不硬失败(exit 0):
采样规模扫描 N ∈ {20, 50, 100, 500, 2000, 10000, 20000}:覆盖率与结局熵随样本量收敛。
小 N 必然低报覆盖率与熵——这不是错误,是「分布量需要样本量」的证据。
节拍标注扰动 把某几拍的 tension 挪动 ±1 / +2,张力曲线 MAE 立刻抬起——交付判据
(tension_mae_max)在规格演化时才有分辨力。
软告警口径:越界只记一条 WARN 并计数,脚本始终 exit 0。数字全部来自本机实跑。
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import nm_16_common as cm # noqa: E402
ROOT = cm.ROOT
SEED = 20261016
SWEEP = [20, 50, 100, 500, 2000, 10000, 20000]
def main() -> int:
spec = cm.load_spec()
m = spec["metrics"]
warns, rows = [], []
print("采样规模扫描(seed=20261016)——覆盖率与结局熵随 N 收敛,小 N 低报是预期")
print(" N 覆盖率‰ 熵(毫比特) 结局数 判据")
for n in SWEEP:
r = cm.sample(spec, n, SEED)
cov, ent, ends = r["coverage_permille"], r["entropy_milli"], len(r["dist"])
rows.append({"n": n, "coverage_permille": cov,
"ending_entropy_milli_bits": ent, "endings_reached": ends})
w = []
if cov < m["coverage_target_permille"]:
w.append(f"覆盖率<{m['coverage_target_permille']}‰")
if ent < m["ending_entropy_min_milli_bits"]:
w.append(f"熵<{m['ending_entropy_min_milli_bits']}")
print(f" {n:<6} {cov:<9} {ent:<11} {ends:<8} {'WARN ' + '/'.join(w) if w else 'OK'}")
warns += [f"N={n} {x}" for x in w]
print("节拍标注扰动——单拍微调 vs 系统性漂移,张力曲线 MAE 随之抬起(判据在演化时才有分辨力)")
beats, target = spec["beats"], list(m["target_tension_curve"])
base = sum(abs(b["tension"] - target[i]) for i, b in enumerate(beats)) * 1000 // len(beats)
print(f" 基线 MAE {base} 毫(阈值 {m['tension_mae_max'] * 1000} 毫)")
probes = []
for label, mutate in (("单拍 0 号 +1", [(0, 1)]), ("单拍 5 号 -1", [(5, -1)]),
("全 16 拍 +1", [(i, 1) for i in range(len(beats))]),
("全 16 拍 +2", [(i, 2) for i in range(len(beats))])):
tb = list(target)
for k, delta in mutate:
tb[k] += delta
mae = sum(abs(b["tension"] - tb[i]) for i, b in enumerate(beats)) * 1000 // len(beats)
probes.append({"label": label, "mae_milli": mae})
over = mae > m["tension_mae_max"] * 1000
print(f" {label:<14} MAE {mae:>5} 毫 {'WARN 超阈值' if over else 'OK'}")
if over:
warns.append(f"扰动 {label} MAE={mae} 超阈值")
assert all(0 <= r["coverage_permille"] <= 1000 for r in rows), "覆盖率越界"
assert all(r["ending_entropy_milli_bits"] >= 0 for r in rows), "熵为负"
print(f"软告警合计 {len(warns)} 条(B 层不硬失败,exit 0)")
out = {"seed": SEED, "sweep": rows, "base_mae_milli": base, "probes": probes,
"warnings": warns, "warning_count": len(warns),
"thresholds": {"coverage_target_permille": m["coverage_target_permille"],
"ending_entropy_min_milli_bits": m["ending_entropy_min_milli_bits"],
"tension_mae_max_milli": m["tension_mae_max"] * 1000}}
with open(os.path.join(ROOT, "out/nm_16_3_perturb.json"), "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_16_3_perturb.json")
print(f"OK nm_16_3_perturb:采样规模与节拍扰动扫描完成,软告警 {len(warns)} 条已报告")
return 0
if __name__ == "__main__":
sys.exit(main())
$ cd /Users/bingrong.yan/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/nm_16_3_perturb.py
采样规模扫描(seed=20261016)——覆盖率与结局熵随 N 收敛,小 N 低报是预期
N 覆盖率‰ 熵(毫比特) 结局数 判据
20 982 1416 4 WARN 熵<1500
50 982 1756 4 OK
100 982 1567 4 OK
500 1000 1747 5 OK
2000 1000 1721 5 OK
10000 1000 1716 5 OK
20000 1000 1714 5 OK
节拍标注扰动——单拍微调 vs 系统性漂移,张力曲线 MAE 随之抬起(判据在演化时才有分辨力)
基线 MAE 0 毫(阈值 1000 毫)
单拍 0 号 +1 MAE 62 毫 OK
单拍 5 号 -1 MAE 62 毫 OK
全 16 拍 +1 MAE 1000 毫 OK
全 16 拍 +2 MAE 2000 毫 WARN 超阈值
软告警合计 2 条(B 层不硬失败,exit 0)
wrote out/nm_16_3_perturb.json
OK nm_16_3_perturb:采样规模与节拍扰动扫描完成,软告警 2 条已报告
扫描说明两件事:小 N 的熵 1416 低于阈值 1500,是样本量不足的低报,不是规格坏了(N ≥ 500 后稳定在 1714~1747);把 16 拍整体抬高 2,MAE 2000 越过阈值 1000——交付判据在规格演化时确实有分辨力。两条软告警都只报告、不硬失败。第 16 章至此闭环:16.1 把三卷装成 17 条断言的检查表,16.2 让三种消费跑同一份规格并证明互不矛盾,16.3 把交付物、接手接口与划界讲清楚——三卷收官。
阅读导航:上一节:16.2 三种消费的总联调 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。