16.3 交付卷三

卷二的交付物不是一份报告,而是一组冻结的动态接口:规格版本、digest 键清单、PRNG 常数、params 键、基线 digest、代理指标口径(含容差)与采样协议。用一个脚本逐条核对它们是否被冻住,并说明卷三《原型、可玩性与品类》接住什么。

16.3 交付卷三

《小游戏开发实战》 那套是单款产品口径——品类选择、立项、变现、上线;本卷是机制与体验口径——一份规格跑起来涌现什么、体验怎么算,三卷共用一份 play_spec.json。产品立项文档的装配与评审 评的是一份文档;本节交接的是一组冻结的接口。卷一到此收口过一次(交出规格与四处消费链),卷二再收口一次:交出的是动态基线与代理指标口径。

交付的不是报告,是冻结的接口

如果交付物是一份报告,卷三只能「照着抄一份指标定义」——那意味着两份真源,迟早漂移。所以卷二的交付方式是冻接口:把卷三将来要依赖的东西写成一份清单,再用脚本逐条核对工作区真值。任何一条对不上,卷三的闸门就会在看不见的地方偏。六项契约:

契约冻的是什么为什么卷三在乎
specVersionspec_version = 1规格结构变了,卷三的品类变体要跟着改
digestKeys固定 12 键、字母序摘要键集合是跨语言契约,漏比被结构性堵死
rngMINSTD(mod/mul)卷三要采样,随机源必须与卷二逐位一致
params8 个规则参数键卷三的品类变体就覆盖在这 8 个参数上
baselineDigest基线 172 字符摘要卷三据此判断「规格没被偷偷改过」
proxyMetrics6 个代理指标 + 整数容差卷三换池算指标时,容差必须容得下抖动

交付清单

清单以数据形式落盘,路径与键名都对得上工作区里的真实文件:

{
  "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 键、代理指标口径,一个都不能漂。

卷二交付了什么

回看整卷,交付物可以收成三句话:

  1. 一条动态度量链:从策略空间到体验度量,19 个 stage 各带口径与证据(16.1)。
  2. 三处消费的对齐:S(Python 仿真)/ P(TS 内核)/ W(浏览器真玩),三处同摘要、同代理指标(16.2)。
  3. 一组冻结的动态接口:六项契约 + 代理指标口径与采样协议(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.pyout/gd_play_16_handoff.json10 条契约全 ok,fail=0
node --test '…/gd_play_16_handoff.test.ts'断言结果6 测试全绿
/opt/homebrew/bin/python3 sim/gd_play_16_handoff_warn.pyout/play16_handoff_warn.json跨池极差 11‰ ≤ 容差 50‰

卷二到此结束。这一卷回答的问题是「一份规格跑起来之后,涌现什么、体验怎么算」——答案是一串可算、可跑、可证明的代理指标,且它们都建立在同一份冻结的规格之上。卷三从这条基线出发,让同一份规格在六个品类下成立:纸面原型、数字原型、可玩原型、可玩性测试,最后用一条机制距离矩阵把六个品类排开。下一卷从这里接住。

阅读导航:上一节:16.2 体验度量的联调 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡