16.1 动态度量装配
《小游戏开发实战》
那套是单款产品口径——品类选择、立项、变现、上线,一条产品线走到底;本卷是机制与体验口径——一份规格跑起来涌现什么、体验怎么算,三卷共用一份 play_spec.json。产品立项文档的装配与评审
装配的是一份文档;本节装配的是一条度量链——前 15 章各算了一个动态或体验量,本节把它们收成一份可核对的契约。
装配的对象是契约,不是图
前 15 章各留下一个数:策略空间熵、支配缺失度、涌现度、相变宽度、心流带宽、体验指标管线……如果只是把它们并排贴出来,那叫「汇总」,不叫「装配」。装配要做的是给每个数补上口径:它属哪一档、单位是什么、是不是代理指标、证据落在哪个文件。这些字段一旦写死,链才可核对——少了哪一环、哪一环偷偷换了单位,都能被机器发现。
装配清单 config/gd_play_16_assembly.json 是一份数据(D 档),19 个 stage 覆盖第 1-16 章。每个 stage 五个字段:tier(S/P/W/B)、metric(指标名)、unit(permille 或 int)、proxy(是否代理指标)、evidence(证据文件)。proxyPolicy 声明全局口径:代理指标只做相对比较、禁绝对化。
{
"id": "gd_play_16_assembly",
"note": "卷二第 16 章动态度量装配:把第 1-15 章的动态/体验指标与第 16 章的 P/W 消费装配成一条可核对的链。全部体验指标都是代理指标(proxy),只做相对比较,禁绝对化。",
"proxyPolicy": { "relativeOnly": true, "absoluteClaims": false, "unit": "permille" },
"frozenSpec": {
"specVersion": 1,
"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}"
},
"stages": [
{ "ch": "01", "tier": "S", "metric": "strategyEntropy", "unit": "permille", "proxy": true, "evidence": "out/play01_entropy.json" },
{ "ch": "02", "tier": "S", "metric": "dominanceGap", "unit": "permille", "proxy": true, "evidence": "out/play02_dominance.json" },
{ "ch": "03", "tier": "S", "metric": "optimalCost", "unit": "int", "proxy": true, "evidence": "out/play03_cost.json" },
{ "ch": "04", "tier": "S", "metric": "emergencePermille", "unit": "permille", "proxy": true, "evidence": "out/play04_emergence.json" },
{ "ch": "05", "tier": "S", "metric": "balanceMetric", "unit": "permille", "proxy": true, "evidence": "out/play05_metric.json" },
{ "ch": "06", "tier": "S", "metric": "phaseWidth", "unit": "int", "proxy": true, "evidence": "out/play06_phase.json" },
{ "ch": "06", "tier": "B", "metric": "phaseRobustness", "unit": "permille", "proxy": true, "evidence": "out/play06_phase_robust.json" },
{ "ch": "07", "tier": "S", "metric": "motiveAlignment", "unit": "permille", "proxy": true, "evidence": "out/play07_model.json" },
{ "ch": "08", "tier": "S", "metric": "typeDrift", "unit": "permille", "proxy": true, "evidence": "out/play08_drift.json" },
{ "ch": "09", "tier": "S", "metric": "tensionCurve", "unit": "permille", "proxy": true, "evidence": "out/play09_tension.json" },
{ "ch": "10", "tier": "S", "metric": "flowBandWidth", "unit": "int", "proxy": true, "evidence": "out/play10_band.json" },
{ "ch": "11", "tier": "S", "metric": "acquisitionCurve", "unit": "permille", "proxy": true, "evidence": "out/play11_policy.json" },
{ "ch": "12", "tier": "S", "metric": "experiencePipeline", "unit": "permille", "proxy": true, "evidence": "out/play12_pipeline.json" },
{ "ch": "13", "tier": "S", "metric": "playabilityComposite", "unit": "permille", "proxy": true, "evidence": "out/play13_metric.json" },
{ "ch": "14", "tier": "S", "metric": "dynamicChart", "unit": "int", "proxy": false, "evidence": "out/play14_curve.svg" },
{ "ch": "15", "tier": "S", "metric": "inversionFeasible", "unit": "permille", "proxy": true, "evidence": "out/play15_feasible.json" },
{ "ch": "15", "tier": "B", "metric": "inversionRobustness", "unit": "permille", "proxy": true, "evidence": "out/play15_robust.json" },
{ "ch": "16", "tier": "P", "metric": "deterministicReplay", "unit": "int", "proxy": false, "evidence": "out/play16_build.json" },
{ "ch": "16", "tier": "W", "metric": "browserPlay", "unit": "int", "proxy": false, "evidence": "out/shots/play16.png" }
]
}
S 层:装配核对
S 层脚本 sim/gd_play_16_assembly.py 把清单读进来,核对两件事:契约自洽(四档齐备、代理指标单位合法、指标名唯一、清单内无浮点),以及与底座对齐(版本与基线 digest 与 play_spec.json 一致)。它不跑任何指标——它跑的是「这份契约能不能被机器核对」。
#!/usr/bin/env python3
# file: sim/gd_play_16_assembly.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_16_assembly.py
"""卷二 16.1 动态度量装配(S 层):把第 1-15 章的指标与第 16 章的 P/W 消费装配成一条可核对的链。
装配的不是「一张图」,是一份**度量契约**:每个 stage 声明它在哪一章、属哪一档(S/P/W/B)、
算哪个指标、单位是什么、是不是代理指标、证据落在哪个文件。本节核对两件事:
① 契约自洽:四档齐备、代理指标单位是千分比或整数、指标名唯一、清单内无浮点;
② 与底座对齐:清单里的 frozenSpec(版本 / 基线 digest)必须与 config/play_spec.json 相同。
产出 out/gd_play_16_assembly.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_assembly.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
TIERS = ("S", "P", "W", "B")
UNITS = ("permille", "int")
def all_ints(obj) -> bool:
"""递归检查 JSON 里没有浮点:数字一律是 int(bool 不算数字)。"""
if isinstance(obj, bool) or isinstance(obj, int):
return True
if isinstance(obj, float):
return False
if isinstance(obj, dict):
return all(all_ints(v) for v in obj.values())
if isinstance(obj, list):
return all(all_ints(v) for v in obj)
return True
def main() -> int:
spec = ashlib.load_spec()
with open(MANIFEST, encoding="utf-8") as f:
man = json.load(f)
stages, fr = man["stages"], man["frozenSpec"]
tier_count = {t: sum(1 for s in stages if s["tier"] == t) for t in TIERS}
proxy = [s for s in stages if s["proxy"]]
names = [s["metric"] for s in stages]
checks = {
"tiersCovered": all(tier_count[t] >= 1 for t in TIERS),
"proxyUnits": all(s["unit"] in UNITS for s in proxy),
"unitsKnown": all(s["unit"] in UNITS for s in stages),
"metricUnique": len(set(names)) == len(names),
"noFloats": all_ints(man),
"specVersion": fr["specVersion"] == spec["spec_version"],
"baselineDigest": fr["baselineDigest"] == ashlib.digest(ashlib.run_spec(spec)),
"proxyPolicy": man["proxyPolicy"]["relativeOnly"] is True
and man["proxyPolicy"]["absoluteClaims"] is False,
}
bad = [k for k, v in checks.items() if not v]
report = {"id": "gd_play_16_assembly", "stages": len(stages), "tiers": tier_count,
"proxyMetrics": len(proxy), "checks": checks, "fail": len(bad),
"proxyPolicy": man["proxyPolicy"]}
os.makedirs(os.path.join(ROOT, "out"), exist_ok=True)
with open(os.path.join(ROOT, "out", "gd_play_16_assembly.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("=== 16.1 动态度量装配核对 ===")
for k, v in checks.items():
print(f" {'ok ' if v else 'FAIL'} {k}")
print(f"stages={len(stages)} tiers={tier_count} proxyMetrics={len(proxy)}")
print(f"fail={len(bad)} {'(装配完整)' if not bad else bad}")
print("wrote out/gd_play_16_assembly.json")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_16_assembly.py:
=== 16.1 动态度量装配核对 ===
ok tiersCovered
ok proxyUnits
ok unitsKnown
ok metricUnique
ok noFloats
ok specVersion
ok baselineDigest
ok proxyPolicy
stages=19 tiers={'S': 15, 'P': 1, 'W': 1, 'B': 2} proxyMetrics=16
fail=0 (装配完整)
wrote out/gd_play_16_assembly.json
fail=0 (装配完整):八条核对全 ok。最容易被忽视的是 proxyUnits——它要求每条 proxy: true 的指标单位落在 {permille, int} 里。这条把「代理指标」从一句口号变成了一个可判定的字段:任何一条体验指标如果没声明单位,或声明了一个会让读者误以为能绝对比较的单位,装配就会红。
B 层:谁是承重件
装配契约在「全部 stage 都在」时是完整的。B 层问一个不同的问题:哪几条 stage 是结构承重件? 它逐条做 leave-one-out——临时移除一个 stage,看四档覆盖(S/P/W/B 各至少 1 条)是否还成立。移除即破档的是承重件,移除后某档只剩 1 条的是脆弱件。全部只打印软告警,进程始终 exit 0。
#!/usr/bin/env python3
# file: sim/gd_play_16_assembly_warn.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_16_assembly_warn.py
"""卷二 16.1 动态度量装配(B 层):装配的结构稳健性——软告警,不硬失败。
装配契约在「全部 stage 都在」时是完整的。B 层问:**哪几条 stage 是结构承重件?** 逐条做
leave-one-out——临时移除一个 stage,看四档覆盖(S/P/W/B 各至少 1 条)是否还成立。移除即破档的
是**承重件**,移除后某档只剩 1 条的是**脆弱件**。再检查代理指标口径:relativeOnly 必须为真、
absoluteClaims 必须为假。全部只打印告警,进程始终 exit 0。产出 out/play16_assembly_warn.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_assembly.json")
TIERS = ("S", "P", "W", "B")
def main() -> int:
with open(MANIFEST, encoding="utf-8") as f:
man = json.load(f)
stages = man["stages"]
full = {t: sum(1 for s in stages if s["tier"] == t) for t in TIERS}
load_bearing, fragile, warnings = [], [], []
for i, s in enumerate(stages):
rest = stages[:i] + stages[i + 1:]
cov = {t: sum(1 for r in rest if r["tier"] == t) for t in TIERS}
missing = [t for t in TIERS if cov[t] == 0]
tag = f"{s['ch']}.{s['metric']}({s['tier']})"
if missing:
load_bearing.append(tag)
warnings.append(f"移除 {s['ch']}.{s['metric']} 会丢掉档位 {missing[0]}——结构承重件")
elif any(cov[t] == 1 for t in TIERS):
fragile.append(tag)
if man["proxyPolicy"]["relativeOnly"] is not True:
warnings.append("代理指标口径未声明 relativeOnly=true——可能被绝对化")
if man["proxyPolicy"]["absoluteClaims"] is not False:
warnings.append("代理指标口径未禁止绝对化(absoluteClaims 应为 false)")
report = {"id": "gd_play_16_assembly_warn", "fullTiers": full, "loadBearing": load_bearing,
"fragile": fragile, "warnings": warnings, "proxyPolicy": man["proxyPolicy"]}
os.makedirs(os.path.join(ROOT, "out"), exist_ok=True)
with open(os.path.join(ROOT, "out", "play16_assembly_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.1 装配结构稳健性(B 层,软告警)===")
print(f"满配档位计数:{full}")
print(f"承重件(移除即丢档):{load_bearing or '无'}")
print(f"脆弱件(移除后某档只剩 1):{len(fragile)} 条")
print(f"warnings={len(warnings)}")
for w in warnings:
print(f" [WARN] {w}")
print("exit=0(软告警不硬失败)")
print("wrote out/play16_assembly_warn.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
=== 16.1 装配结构稳健性(B 层,软告警)===
满配档位计数:{'S': 15, 'P': 1, 'W': 1, 'B': 2}
承重件(移除即丢档):['16.deterministicReplay(P)', '16.browserPlay(W)']
脆弱件(移除后某档只剩 1):17 条
warnings=2
[WARN] 移除 16.deterministicReplay 会丢掉档位 P——结构承重件
[WARN] 移除 16.browserPlay 会丢掉档位 W——结构承重件
exit=0(软告警不硬失败)
wrote out/play16_assembly_warn.json
读数很直白:P 与 W 各只有 1 条,所以它们就是承重件——一旦移除,整个装配就丢了「确定性回放」或「浏览器真玩」这一档。这不是缺陷,是事实:本卷前 15 章都落在 S 与 B 两档,P 与 W 是本章(第 16 章)才接进来的。所以 B 层的告警在提醒:装配的四档完整,是靠第 16 章这两条撑住的,卷三若要复用这套装配,这两条必须一起带走。软告警不硬失败——它报告结构事实,不判定「错」。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
/opt/homebrew/bin/python3 sim/gd_play_16_assembly.py | out/gd_play_16_assembly.json | 8 条核对全 ok,fail=0 |
/opt/homebrew/bin/python3 sim/gd_play_16_assembly_warn.py | out/play16_assembly_warn.json | 承重件 2、脆弱件 17、软告警 2 |
装配契约立住了。下一节把这条链真的跑起来:S / P / W 三处各跑一遍同一份规格,看它们是否给出同一条摘要、同一个代理指标。
阅读导航:上一节:15.3 反推的代价与取舍 · 下一节:16.2 体验度量的联调 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。