16.3 交付卷三
《小游戏开发实战》
那套是单款产品口径——品类选择、立项、变现、上线;本卷是机制与体验口径——一份规格跑起来涌现什么、体验怎么算,三卷共用一份 play_spec.json。产品立项文档的装配与评审
评的是一份文档;本节交接的是一组冻结的接口。卷一到此收口过一次(交出规格与四处消费链),卷二再收口一次:交出的是动态基线与代理指标口径。
交付的不是报告,是冻结的接口
如果交付物是一份报告,卷三只能「照着抄一份指标定义」——那意味着两份真源,迟早漂移。所以卷二的交付方式是冻接口:把卷三将来要依赖的东西写成一份清单,再用脚本逐条核对工作区真值。任何一条对不上,卷三的闸门就会在看不见的地方偏。六项契约:
| 契约 | 冻的是什么 | 为什么卷三在乎 |
|---|---|---|
| specVersion | spec_version = 1 | 规格结构变了,卷三的品类变体要跟着改 |
| digestKeys | 固定 12 键、字母序 | 摘要键集合是跨语言契约,漏比被结构性堵死 |
| rng | MINSTD(mod/mul) | 卷三要采样,随机源必须与卷二逐位一致 |
| params | 8 个规则参数键 | 卷三的品类变体就覆盖在这 8 个参数上 |
| baselineDigest | 基线 172 字符摘要 | 卷三据此判断「规格没被偷偷改过」 |
| proxyMetrics | 6 个代理指标 + 整数容差 | 卷三换池算指标时,容差必须容得下抖动 |
交付清单
清单以数据形式落盘,路径与键名都对得上工作区里的真实文件:
{
"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}",
"proxyMetrics": [
{ "name": "strategyEntropy", "unit": "permille", "relativeOnly": true, "tolerancePermille": 50 },
{ "name": "dominanceGap", "unit": "permille", "relativeOnly": true, "tolerancePermille": 50 },
{ "name": "emergencePermille", "unit": "permille", "relativeOnly": true, "tolerancePermille": 50 },
{ "name": "flowBandWidth", "unit": "int", "relativeOnly": true, "tolerancePermille": 0 },
{ "name": "hpMarginPermille", "unit": "permille", "relativeOnly": true, "tolerancePermille": 50 },
{ "name": "playabilityComposite", "unit": "permille", "relativeOnly": true, "tolerancePermille": 80 }
],
"sampling": { "seedPoolSize": 32, "cellsPerScan": 32, "coarseStep": 5, "bisectMaxRounds": 8 }
},
"consumption": {
"S": "sim/ashlib.py",
"P": "core/src/ashdeck/combat.ts",
"W": "web/build_play16.mjs",
"B": "sim/gd_play_16_handoff_warn.py"
},
"handedTo": [
"六个品类变体的策略空间",
"变体的支配检测与可解率",
"变体的可玩性闸门回归",
"机制距离矩阵的特征向量"
]
}
交付核对
核对脚本读清单、读规格、读四处入口文件,逐条比对,任何一条对不上就退出非零:
#!/usr/bin/env python3
# file: sim/gd_play_16_handoff.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_16_handoff.py
"""卷二 16.3 交付卷三(S 层):交给卷三的不是「一份报告」,是一组**冻结的动态接口**。
卷三要做「同一份规格在六个品类下成立」——它复用卷二算出的动态量(策略熵、支配缺失度、涌现度、
心流带宽、可玩性组合)与采样协议,还复用同一份 play_spec.json。所以交付 = 冻接口:
① 规格版本 / PRNG 常数 / params 键 / digest 键 / 基线 digest —— 与卷一一致的底座契约;
② 代理指标口径:每个体验指标必须 relativeOnly=true、带整数容差(千分比);
③ 采样协议:种子池大小 / 每格种子数 / 粗扫步长 / 二分轮数,全整数;
④ 四处消费入口文件存在。
逐条与工作区真值比对,任何一条对不上,卷三的闸门就会在看不见的地方漂。落 out/gd_play_16_handoff.json。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
MANIFEST = os.path.join(ROOT, "config/gd_play_16_handoff.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def main() -> int:
spec = ashlib.load_spec()
with open(MANIFEST, encoding="utf-8") as f:
man = json.load(f)
fr = man["frozen"]
pm, samp = fr["proxyMetrics"], fr["sampling"]
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"] == ashlib.digest(ashlib.run_spec(spec)),
"proxyRelativeOnly": all(m["relativeOnly"] is True for m in pm),
"proxyHasTolerance": all(isinstance(m["tolerancePermille"], int) for m in pm),
"samplingIntegers": all(isinstance(v, int) for v in samp.values()),
"consumptionExists": all(os.path.exists(os.path.join(ROOT, p))
for p in man["consumption"].values()),
"handedToNonEmpty": len(man["handedTo"]) >= 1,
}
bad = [k for k, v in checks.items() if not v]
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": ashlib.digest(ashlib.run_spec(spec))}
os.makedirs(os.path.join(ROOT, "out"), exist_ok=True)
with open(os.path.join(ROOT, "out", "gd_play_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("=== 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'])} proxyMetrics={len(pm)}")
print(f"baselineDigest={out['baselineDigest']}")
print(f"handedTo={man['handedTo']}")
print(f"fail={len(bad)} {'(动态接口全部冻结)' if not bad else bad}")
print("wrote out/gd_play_16_handoff.json")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
=== 16.3 卷二 → 卷三 动态接口冻结核对 ===
ok specVersion
ok digestKeys
ok rng
ok params
ok baselineDigest
ok proxyRelativeOnly
ok proxyHasTolerance
ok samplingIntegers
ok consumptionExists
ok handedToNonEmpty
specVersion=1 digestKeys=12 params=8 proxyMetrics=6
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=['六个品类变体的策略空间', '变体的支配检测与可解率', '变体的可玩性闸门回归', '机制距离矩阵的特征向量']
fail=0 (动态接口全部冻结)
wrote out/gd_play_16_handoff.json
十条契约全 ok,fail=0 (动态接口全部冻结)。baselineDigest 与 16.2 里三处一致的那条字符串是同一个——接口冻结不是另起炉灶,它冻的就是 16.2 已经验证过的那条摘要。proxyMetrics=6 是新的一层:每条代理指标都带 relativeOnly: true 与一个整数容差。
接口冻结在 P 层的复现
S 侧核对了,P 侧还要再复现一遍——因为卷三同样会从 TypeScript 侧消费这套接口。六项契约在 TS 内核上重新断言:
# snippet: 跑 P 层交付契约测试
node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_play_16_handoff.test.ts'
✔ 契约①:规格版本与 PRNG 常数为 Lehmer MINSTD
✔ 契约②:params 恰为 8 键、与规格一致
✔ 契约③:digest 恰为 12 键、字母序(漏比被结构性堵死)
✔ 契约④:基线 digest 逐字冻结(卷三据此对齐)
✔ 契约⑤:代理指标全部 relativeOnly、禁绝对化
✔ 契约⑥:采样协议全整数、不变量在冻结接口上仍成立
ℹ tests 6
ℹ pass 6
ℹ fail 0
六条全绿。第五条最容易被忽视:它断言每条代理指标 relativeOnly === true——如果哪天有人把某个体验指标写成「绝对分值」而没标注相对化,跨品类比较会立刻失真;这条测试把这个隐患提前钉死在接口层。
B 层:容差对采样协议够不够
冻结的容差必须容得下「换一批种子」带来的抖动——卷三会用自己的种子池去算。B 层脚本把种子池整体平移四个偏移,各算一遍 hpMarginPermille,报出跨池极差并与冻结容差比对;极差超容差只软告警,进程始终 exit 0:
#!/usr/bin/env python3
# file: sim/gd_play_16_handoff_warn.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_16_handoff_warn.py
"""卷二 16.3 交付卷三(B 层):冻结的代理指标口径对**采样协议**有多敏感——软告警,不硬失败。
16.3 冻给卷三的是一组带容差的代理指标(hpMarginPermille 等)。卷三会用**自己的**种子池去算,
所以冻结的容差必须容得下「换一批种子」带来的抖动。本脚本把种子池整体平移四个偏移
(0/100/200/400),各算一遍 hpMarginPermille,报出跨池极差并与冻结容差比对:极差 ≤ 容差即口径
稳健,极差 > 容差则软告警——冻结容差偏紧,卷三换池可能误判。只打印告警,进程始终 exit 0。
产出 out/play16_handoff_warn.json。全整数、无浮点、无 random。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
MANIFEST = os.path.join(ROOT, "config/gd_play_16_handoff.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
OFFSETS = (0, 100, 200, 400)
def hp_margin_permille(spec, seq, pool) -> int:
total = 0
for sd in pool:
s = json.loads(json.dumps(spec))
s["seed"] = sd
total += ashlib.run_spec(s, seq)["playerHp"]
return 1000 * total // (len(pool) * spec["params"]["playerHp"])
def main() -> int:
spec = ashlib.load_spec()
seq = list(spec["strategy"])
with open(MANIFEST, encoding="utf-8") as f:
man = json.load(f)
size = man["frozen"]["sampling"]["seedPoolSize"]
tol = next(m["tolerancePermille"] for m in man["frozen"]["proxyMetrics"]
if m["name"] == "hpMarginPermille")
rows, warnings = [], []
for off in OFFSETS:
pool = [301 + off + i for i in range(size)]
rows.append({"offset": off, "seedFrom": pool[0], "seedTo": pool[-1],
"hpMarginPermille": hp_margin_permille(spec, seq, pool)})
vals = [r["hpMarginPermille"] for r in rows]
spread = max(vals) - min(vals)
if spread > tol:
warnings.append(f"跨池极差 {spread}‰ > 冻结容差 {tol}‰——容差偏紧,卷三换池可能误判")
report = {"id": "gd_play_16_handoff_warn", "proxy": "hpMarginPermille",
"tolerancePermille": tol, "rows": rows, "spreadPermille": spread,
"warnings": warnings}
os.makedirs(os.path.join(ROOT, "out"), exist_ok=True)
with open(os.path.join(ROOT, "out", "play16_handoff_warn.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("=== 16.3 代理指标口径对采样协议的稳健性(B 层,软告警)===")
print(f"hpMarginPermille 冻结容差 = {tol}‰")
for r in rows:
print(f" 种子 {r['seedFrom']}-{r['seedTo']} hpMarginPermille={r['hpMarginPermille']}")
print(f"跨池极差={spread}‰ warnings={len(warnings)}")
for w in warnings:
print(f" [WARN] {w}")
print("exit=0(软告警不硬失败)")
print("wrote out/play16_handoff_warn.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
=== 16.3 代理指标口径对采样协议的稳健性(B 层,软告警)===
hpMarginPermille 冻结容差 = 50‰
种子 301-332 hpMarginPermille=823
种子 401-432 hpMarginPermille=829
种子 501-532 hpMarginPermille=826
种子 701-732 hpMarginPermille=834
跨池极差=11‰ warnings=0
exit=0(软告警不硬失败)
wrote out/play16_handoff_warn.json
四个种子池给出的余裕度在 823-834 之间,跨池极差 11‰,远小于冻结容差 50‰——warnings=0。这说明冻结的容差够宽:卷三换一批种子去算,指标不会越界。如果哪天极差超过容差,脚本会打印软告警,提示「容差偏紧」——它报告口径事实,不判定「错」。
卷三接住什么
| 卷三主题 | 建立在卷二的什么之上 | 卷二已交付的证据 |
|---|---|---|
| 六品类变体 | 同一份规格 + params 8 键 | 16.3 的基线 digest |
| 变体的策略空间 | 策略空间熵(代理) | 第 1 章的熵口径 |
| 变体的支配检测 | 支配缺失度(代理) | 第 2 章的检测 |
| 变体的可玩性闸门 | 可玩性组合指标(代理) | 第 13 章的指标 |
| 机制距离矩阵 | 品类特征向量 | 第 14 章的动态可视化口径 |
卷三不做「新写一份规格」,它做的是在这份规格上换品类:同一份 play_spec.json,只覆盖 rules / params 段,实例化成六个品类变体。所以卷二交付的关键不是指标多,而是口径稳——spec_version、digest 键、PRNG 常数、params 键、代理指标口径,一个都不能漂。
卷二交付了什么
回看整卷,交付物可以收成三句话:
- 一条动态度量链:从策略空间到体验度量,19 个 stage 各带口径与证据(16.1)。
- 三处消费的对齐:S(Python 仿真)/ P(TS 内核)/ W(浏览器真玩),三处同摘要、同代理指标(16.2)。
- 一组冻结的动态接口:六项契约 + 代理指标口径与采样协议(16.3),卷三据此接续,不必重造。
三句话对应三种可核对的产物:out/gd_play_16_assembly.json、out/gd_play_16_integration.json、out/gd_play_16_handoff.json。它们都在工作区里,随正文可重建。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
/opt/homebrew/bin/python3 sim/gd_play_16_handoff.py | out/gd_play_16_handoff.json | 10 条契约全 ok,fail=0 |
node --test '…/gd_play_16_handoff.test.ts' | 断言结果 | 6 测试全绿 |
/opt/homebrew/bin/python3 sim/gd_play_16_handoff_warn.py | out/play16_handoff_warn.json | 跨池极差 11‰ ≤ 容差 50‰ |
卷二到此结束。这一卷回答的问题是「一份规格跑起来之后,涌现什么、体验怎么算」——答案是一串可算、可跑、可证明的代理指标,且它们都建立在同一份冻结的规格之上。卷三从这条基线出发,让同一份规格在六个品类下成立:纸面原型、数字原型、可玩原型、可玩性测试,最后用一条机制距离矩阵把六个品类排开。下一卷从这里接住。
阅读导航:上一节:16.2 体验度量的联调 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。