16.3 交付卷二
《小游戏开发实战》的产品立项文档装配与评审
评的是立项文档,交付物验收与交接
交接的是一套产品交付物(文档、原型、验收清单);本节交接的是一组冻结的接口。《小游戏开发实战》
那套是单款产品从立项到上线的口径,本套是机制口径。卷一到此收口:前面 15 章建起的那份 play_spec.json 与四处消费链,要以可核对的形式交给卷二《策略、涌现与体验度量》。
交付的不是文档,是冻结的接口
如果交付物是一份文档,卷二只能「照着重写一份规格」——那意味着两份真源,迟早漂移。所以卷一的交付方式是冻接口:把卷二将来要依赖的东西写成一份清单,再用脚本逐条核对工作区真值。任何一条对不上,卷二的度量就会在看不见的地方偏。六项契约:
| 契约 | 冻的是什么 | 为什么卷二在乎 |
|---|---|---|
| specVersion | spec_version = 1 | 规格结构变了,卷二的解析器要跟着改 |
| digestKeys | 固定 12 键、字母序 | 摘要键集合是跨语言契约,漏比被结构性堵死 |
| rng | MINSTD(mod/mul) | 卷二要采样,随机源必须与卷一逐位一致 |
| params | 8 个规则参数键 | 卷二的策略空间就建立在这些参数上 |
| baselineDigest | 基线 172 字符摘要 | 卷二据此判断「规格没被偷偷改过」 |
| consumption | 四处入口文件 | 卷二直接复用 S/P/W/B,不重造轮子 |
交付清单
清单以数据形式落盘,路径与键名都对得上工作区里的真实文件:
{
"spec": "ashdeck",
"specVersion": 1,
"fromVolume": "玩法机制与可执行规格",
"toVolume": "策略、涌现与体验度量",
"frozen": {
"digestKeys": [
"block", "cardCount", "damageConserved", "dealt", "enemyHp", "energy",
"hand", "over", "played", "playerHp", "turn", "won"
],
"rng": { "algo": "lehmer-minstd", "mod": 2147483647, "mul": 48271 },
"params": [
"enemyAttack", "enemyHp", "energyMax", "handSize",
"playerHp", "punishHp", "rewardGold", "turnLimit"
],
"baselineDigest": "{\"block\":0,\"cardCount\":12,\"damageConserved\":true,\"dealt\":50,\"enemyHp\":0,\"energy\":1,\"hand\":[\"strike\",\"jab\",\"heavy\"],\"over\":true,\"played\":9,\"playerHp\":48,\"turn\":4,\"won\":true}"
},
"consumption": {
"S": "sim/ashlib.py",
"P": "core/src/ashdeck/combat.ts",
"W": "web/build_mech16.mjs",
"B": "sim/gd_mech_16_integration.py"
},
"handedTo": [
"策略空间枚举与 Shannon 熵",
"支配策略检测",
"涌现度量",
"体验度量与置信区间"
]
}
交付核对
核对脚本读清单、读规格、读四处入口文件,逐条比对,任何一条对不上就退出非零:
#!/usr/bin/env python3
# file: sim/gd_mech_16_handoff.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_16_handoff.py
"""16.3 交付卷二:卷一交给卷二的不是「一份文档」,而是一组**冻结的接口**。
卷二的策略空间 / 支配策略 / 涌现 / 体验度量都建立在同一份 play_spec.json 与同一套
跨语言口径之上。所以「交付」= 把接口冻住并留下可核对的证据:
① 规格版本(spec_version)不变;
② digest 键清单固定 12 键、字母序(漏比被结构性堵死);
③ PRNG 常数(MINSTD:mod=2147483647, mul=48271)不变;
④ params 8 键不变;
⑤ 基线 digest 逐字不变;
⑥ 四处消费的入口文件都存在。
本脚本读 config/gd_mech_16_handoff.json(D 档交付清单),逐条与工作区真值比对,
落 out/gd_mech_16_handoff.json。任何一条对不上,卷二的度量就会在看不见的地方漂移。
"""
from __future__ import annotations
import json
import os
import sys
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
MANIFEST = os.path.join(ROOT, "config", "gd_mech_16_handoff.json")
def main() -> int:
spec = ashlib.load_spec()
man = json.load(open(MANIFEST, encoding="utf-8"))
fr = man["frozen"]
py_digest = ashlib.digest(ashlib.run_spec(spec))
checks = {
"specVersion": man["specVersion"] == spec["spec_version"],
"digestKeys": fr["digestKeys"] == ashlib.DIGEST_KEYS,
"rng": fr["rng"] == spec["rng"] and fr["rng"]["mod"] == ashlib.RNG_MOD
and fr["rng"]["mul"] == ashlib.RNG_MUL,
"params": fr["params"] == sorted(spec["params"].keys()),
"baselineDigest": fr["baselineDigest"] == py_digest,
"consumption": all(os.path.exists(os.path.join(ROOT, p))
for p in man["consumption"].values()),
}
bad = [k for k, v in checks.items() if not v]
print("=== 16.3 卷一 → 卷二 接口冻结核对 ===")
for k, v in checks.items():
print(f" {'ok ' if v else 'FAIL'} {k}")
print(f"specVersion={man['specVersion']} digestKeys={len(fr['digestKeys'])} "
f"params={len(fr['params'])}")
print(f"baselineDigest={py_digest}")
print(f"handedTo={man['handedTo']}")
out = {
"from": man["fromVolume"],
"to": man["toVolume"],
"spec": spec["id"],
"specVersion": man["specVersion"],
"frozen": fr,
"consumption": man["consumption"],
"handedTo": man["handedTo"],
"checks": checks,
"fail": len(bad),
"baselineDigest": py_digest,
}
with open(os.path.join(ROOT, "out", "gd_mech_16_handoff.json"), "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"fail={len(bad)} {'(接口全部冻结)' if not bad else bad}")
print("wrote out/gd_mech_16_handoff.json")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
S 实跑输出
=== 16.3 卷一 → 卷二 接口冻结核对 ===
ok specVersion
ok digestKeys
ok rng
ok params
ok baselineDigest
ok consumption
specVersion=1 digestKeys=12 params=8
baselineDigest={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
handedTo=['策略空间枚举与 Shannon 熵', '支配策略检测', '涌现度量', '体验度量与置信区间']
fail=0 (接口全部冻结)
wrote out/gd_mech_16_handoff.json
六条契约全 ok,fail=0 (接口全部冻结)。baselineDigest 与 16.2 里四处一致的那条字符串是同一个——这说明接口冻结不是另起炉灶,它冻的就是 16.2 已经验证过的那条摘要。
接口冻结在 P 层的复现
S 侧核对了,P 侧还要再复现一遍——因为卷二同样会从 TypeScript 侧消费这套接口。六条契约在 TS 内核上重新断言:
// file: core/test/gd_mech_16_handoff.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_16_handoff.test.ts'
//
// 16.3 交付卷二的接口冻结在 TS 侧的复现:卷二依赖的六项契约必须在 P 层同样成立。
// 这里断言的是**接口**而不是实现细节——digest 键集合、PRNG 常数、规格版本、
// params 键集合、基线 digest。任何一项被改动,本测试即红。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec, invariantsHold } from "../src/ashdeck/combat.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import { RNG_MOD, RNG_MUL } from "../src/ashdeck/rng.ts";
import type { Spec } from "../src/ashdeck/types.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const SPEC: Spec = JSON.parse(
readFileSync(join(ROOT, "config", "play_spec.json"), "utf8"),
);
test("契约①:规格版本为 1", () => {
assert.equal(SPEC.spec_version, 1);
});
test("契约②:PRNG 常数为 Lehmer MINSTD", () => {
assert.equal(RNG_MOD, 2147483647);
assert.equal(RNG_MUL, 48271);
assert.equal(SPEC.rng.mod, RNG_MOD);
assert.equal(SPEC.rng.mul, RNG_MUL);
});
test("契约③:params 恰为 8 键", () => {
assert.deepEqual(Object.keys(SPEC.params).sort(), [
"enemyAttack", "enemyHp", "energyMax", "handSize",
"playerHp", "punishHp", "rewardGold", "turnLimit",
]);
});
test("契约④:digest 恰为 12 键、字母序(漏比被结构性堵死)", () => {
const keys = Object.keys(JSON.parse(digestSnap(runSpec(SPEC))));
assert.equal(keys.length, 12);
assert.deepEqual(keys, [...keys].sort());
});
test("契约⑤:基线 digest 逐字冻结(卷二据此对齐)", () => {
assert.equal(
digestSnap(runSpec(SPEC)),
'{"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,' +
'"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,' +
'"playerHp":48,"turn":4,"won":true}',
);
});
test("契约⑥:不变量在冻结接口上仍成立", () => {
assert.equal(invariantsHold(SPEC, runSpec(SPEC)), true);
});
# snippet: 跑 P 层交付契约测试
node --test 'core/test/gd_mech_16_handoff.test.ts'
✔ 契约①:规格版本为 1
✔ 契约②:PRNG 常数为 Lehmer MINSTD
✔ 契约③:params 恰为 8 键
✔ 契约④:digest 恰为 12 键、字母序(漏比被结构性堵死)
✔ 契约⑤:基线 digest 逐字冻结(卷二据此对齐)
✔ 契约⑥:不变量在冻结接口上仍成立
ℹ tests 6
ℹ pass 6
ℹ fail 0
六条全绿。第四条最容易被忽视:它断言 digest 的键恰好 12 个且已排序——如果哪天有人给摘要加了第 13 个键而 Python 侧没加,跨语言比对会立刻炸;这条测试把这个隐患提前钉死在接口层。
卷二接住什么
| 卷二主题 | 建立在卷一的什么之上 | 卷一已交付的证据 |
|---|---|---|
| 策略空间 | strategy 序列 + params | 16.2 的四处一致基线 |
| 支配策略检测 | 同一份规格 + 确定性回放 | P 层 runSpec / digestSnap |
| 涌现度量 | 机制耦合(第 12 章)+ 可达状态 | 第 10 章状态可达性 |
| 体验度量 | 机制量 → 体验量的映射(第 14 章) | 代理指标与容差口径 |
卷二不做「新写一份规格」,它做的是在这份规格上算动态:策略空间有多大、有没有支配策略、涌现出多少模式、这些机制量映射成什么体验量。所以卷一交付的关键不是内容多,而是接口稳——spec_version、digest 键、PRNG 常数、params 键,一个都不能漂。
卷一交付了什么
回看整卷,交付物可以收成三句话:
- 一份可执行规格:
config/play_spec.json,全整数、无浮点、自带策略序列与不变量清单。 - 四处消费链:S(Python 仿真)/ P(TS 内核)/ W(浏览器原型)/ B(参数扰动),四处同摘要。
- 一组冻结的接口:本节的六项契约,卷二据此接续,不必重造。
三句话对应三种可核对的产物:play_spec.json、out/mech16_digests.json、out/gd_mech_16_handoff.json。它们都在工作区里,随正文可重建。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
/opt/homebrew/bin/python3 sim/gd_mech_16_handoff.py | out/gd_mech_16_handoff.json | 6 条契约全 ok,fail=0 |
node --test 'core/test/gd_mech_16_handoff.test.ts' | 断言结果 | 6 测试全绿 |
| 基线 digest | S / P / W 三处对照 | 172 字符,逐字相同 |
卷一到此结束。这一卷回答的问题是「玩法设计文档能不能写成一份可执行、可证明、跨宿主一致的规格」——答案是能,而且这份规格现在就摆在工作区里,四处消费、逐字一致。卷二从这条基线出发,把「规格」推进到「规格跑起来之后的动态与体验」:策略空间、支配策略、涌现、心流带宽、体验度量。卷三再往外走一层,让同一份规格在六个品类下成立。下一卷从这里接住。
阅读导航:上一节:16.2 四处消费的联调 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。