16.3 交付卷二

卷一的交付物不是一份文档,而是一组冻结的接口:规格版本、digest 键清单、PRNG 常数、params 键集合、基线 digest、四处消费入口。用一个脚本逐条核对它们是否被冻住,并说明卷二《策略、涌现与体验度量》接住什么——策略空间、支配策略、涌现与体验度量。

16.3 交付卷二

《小游戏开发实战》的产品立项文档装配与评审 评的是立项文档,交付物验收与交接 交接的是一套产品交付物(文档、原型、验收清单);本节交接的是一组冻结的接口。《小游戏开发实战》 那套是单款产品从立项到上线的口径,本套是机制口径。卷一到此收口:前面 15 章建起的那份 play_spec.json 与四处消费链,要以可核对的形式交给卷二《策略、涌现与体验度量》。

交付的不是文档,是冻结的接口

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

契约冻的是什么为什么卷二在乎
specVersionspec_version = 1规格结构变了,卷二的解析器要跟着改
digestKeys固定 12 键、字母序摘要键集合是跨语言契约,漏比被结构性堵死
rngMINSTD(mod/mul)卷二要采样,随机源必须与卷一逐位一致
params8 个规则参数键卷二的策略空间就建立在这些参数上
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 序列 + params16.2 的四处一致基线
支配策略检测同一份规格 + 确定性回放P 层 runSpec / digestSnap
涌现度量机制耦合(第 12 章)+ 可达状态第 10 章状态可达性
体验度量机制量 → 体验量的映射(第 14 章)代理指标与容差口径

卷二不做「新写一份规格」,它做的是在这份规格上算动态:策略空间有多大、有没有支配策略、涌现出多少模式、这些机制量映射成什么体验量。所以卷一交付的关键不是内容多,而是接口稳——spec_version、digest 键、PRNG 常数、params 键,一个都不能漂。

卷一交付了什么

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

  1. 一份可执行规格:config/play_spec.json,全整数、无浮点、自带策略序列与不变量清单。
  2. 四处消费链:S(Python 仿真)/ P(TS 内核)/ W(浏览器原型)/ B(参数扰动),四处同摘要。
  3. 一组冻结的接口:本节的六项契约,卷二据此接续,不必重造。

三句话对应三种可核对的产物:play_spec.json、out/mech16_digests.json、out/gd_mech_16_handoff.json。它们都在工作区里,随正文可重建。

本节实跑清单

命令产物实测
/opt/homebrew/bin/python3 sim/gd_mech_16_handoff.pyout/gd_mech_16_handoff.json6 条契约全 ok,fail=0
node --test 'core/test/gd_mech_16_handoff.test.ts'断言结果6 测试全绿
基线 digestS / P / W 三处对照172 字符,逐字相同

卷一到此结束。这一卷回答的问题是「玩法设计文档能不能写成一份可执行、可证明、跨宿主一致的规格」——答案是能,而且这份规格现在就摆在工作区里,四处消费、逐字一致。卷二从这条基线出发,把「规格」推进到「规格跑起来之后的动态与体验」:策略空间、支配策略、涌现、心流带宽、体验度量。卷三再往外走一层,让同一份规格在六个品类下成立。下一卷从这里接住。

阅读导航:上一节:16.2 四处消费的联调 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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