16.1 规格装配检查表

把全书散落的检查项汇成一张可执行的装配检查表:规格字段完整性、模式与语义校验、不变量清单、确定性、三处 digest 一致、B 层扰动可跑、整数与跨语言口径、W 产物自足。每项给一条真命令与一条通过判据,用一个脚本逐项真跑落盘——这是卷一交付前的最后一道闸门。

16.1 规格装配检查表

《小游戏开发实战》的产品立项文档装配与评审 管的是立项文档怎么拼、怎么评;本章管的是可执行规格的四处消费联调。分工也说清楚:《小游戏开发实战》 那套是单款产品口径(一款产品从立项走到上线),本套是机制口径(同一份 play_spec.json 被 S / P / W / B 四处消费)。本节是卷一交付前的最后一道闸门:把前 15 章散落各处的检查项,收成一张能跑的清单。

检查项为什么必须「可执行」

一份检查表如果只能靠人读,它就会随版本漂移:写表的人记得第 11 章要求整数,接手的人不一定记得。所以本卷的检查表不是文档,是数据 + 执行器——config/gd_mech_16_checklist.json 存「查什么、怎么查、什么算过」,sim/gd_mech_16_assembly.py 负责逐项真跑并把结果落盘。清单改了,执行器自动跟着改;没跑过,就等于没检查。

三条原则缺一条,这张表就退化成许愿池:每项一条真命令(不是「确认字段完整」,而是 python3 sim/gd_mech_16_assembly.py --item C1,粘进终端就能跑);每项一条通过判据(不是「看起来对」,而是「顶层 10 键与 params 8 键齐全且无浮点」这种可判定的句子);失败即退出非零(exit 0 是过、exit 1 是不过,CI 里一个 && 就能挂住)。

八项检查

项类别来自通过判据
C1规格字段完整性第 2 章顶层 10 键 + params 8 键齐全,无浮点
C2模式与语义校验第 2 章牌数=12、最大费用≤能量上限、rng 常数为 MINSTD
C3不变量清单第 4 / 10 章基线快照满足 6 条不变量
C4确定性第 9 / 11 章同种子两遍 digest 相同,换种子改变
C5三处 digest 一致第 15 章S(Python)=P(TS)=W(浏览器) 逐字相同
C6B 层扰动可跑第 6 章覆盖 energyMax=2 后 digest 改变且不变量保持
C7整数与跨语言口径第 11 章无浮点、digest 固定 12 键且字母序
C8W 产物自足第 5 章产物含内核标记且零外部引用

检查表本体

检查表以数据形式存盘,这样任何一项的判据都能被脚本直接读出来核对,而不是写在注释里等人抄错。八项里只有 C5 跨宿主,它把前面所有章节的成果串成一根链条,所以必须放在最后一项;其余七项都是单机自足的,只读 config/play_spec.json 与 out/ 下的产物。

{
  "spec": "ashdeck",
  "volume": "玩法机制与可执行规格",
  "source": "play_spec.json",
  "items": [
    {
      "id": "C1",
      "group": "规格字段完整性",
      "chapter": "第 2 章",
      "command": "/opt/homebrew/bin/python3 sim/gd_mech_16_assembly.py --item C1",
      "pass": "顶层 10 键与 params 8 键齐全,且所有数值字段为 int(无浮点)"
    },
    {
      "id": "C2",
      "group": "模式与语义校验",
      "chapter": "第 2 章",
      "command": "/opt/homebrew/bin/python3 sim/gd_mech_16_assembly.py --item C2",
      "pass": "牌数=12、最大费用<=能量上限、手牌<=牌库、rng 常数=MINSTD、种子为正整数"
    },
    {
      "id": "C3",
      "group": "不变量清单",
      "chapter": "第 4 章 / 第 10 章",
      "command": "/opt/homebrew/bin/python3 sim/gd_mech_16_assembly.py --item C3",
      "pass": "基线快照满足 6 条不变量:牌数守恒、伤害守恒、能量在界、格挡非负、双方血量在界"
    },
    {
      "id": "C4",
      "group": "确定性",
      "chapter": "第 9 章 / 第 11 章",
      "command": "/opt/homebrew/bin/python3 sim/gd_mech_16_assembly.py --item C4",
      "pass": "同种子同策略两遍 digest 逐字相同;换种子 digest 改变"
    },
    {
      "id": "C5",
      "group": "三处 digest 一致",
      "chapter": "第 15 章",
      "command": "/opt/homebrew/bin/python3 sim/gd_mech_16_integration.py",
      "pass": "S(Python)=P(TS)=W(浏览器) 三方 digest 逐字相同(W 侧需先跑 verify_mech16.mjs)"
    },
    {
      "id": "C6",
      "group": "B 层扰动可跑",
      "chapter": "第 6 章",
      "command": "/opt/homebrew/bin/python3 sim/gd_mech_16_assembly.py --item C6",
      "pass": "覆盖 energyMax=2 后 digest 改变,且扰动快照仍满足全部不变量"
    },
    {
      "id": "C7",
      "group": "整数与跨语言口径",
      "chapter": "第 11 章",
      "command": "/opt/homebrew/bin/python3 sim/gd_mech_16_assembly.py --item C7",
      "pass": "config 内无浮点字面量;digest 键清单为固定 12 键、字母序、无空格分隔符"
    },
    {
      "id": "C8",
      "group": "W 产物自足",
      "chapter": "第 5 章",
      "command": "/opt/homebrew/bin/python3 sim/gd_mech_16_assembly.py --item C8",
      "pass": "out/mech16.html 含内核源码标记且零外部引用(无 CDN / 无 script src)"
    }
  ]
}

执行器

执行器只做一件事:按 CHECKS 表逐项调用,收集 (状态, 说明),最后落盘。--item Cn 让单跑一项成为可能——排查时不用跑全表。C5 是唯一跨宿主的项:S 侧现算,P 侧用 node 直接 import core 的 TS 内核现算,W 侧读 out/mech16_digests.json;如果 W 还没跑,它记 PENDING 而不是 FAIL,因为「没跑」和「跑错了」是两件事。

#!/usr/bin/env python3
# file: sim/gd_mech_16_assembly.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_16_assembly.py
"""16.1 规格装配检查表:把全书散落的检查项汇成一张**可执行**清单,逐项真跑。

检查表本体是 config/gd_mech_16_checklist.json(D 档)。本脚本是它的执行器:每项给一条
真命令与一条通过判据(`--item C5` 可单跑一项),跑完落 out/gd_mech_16_assembly.json。
C1 字段完整 / C2 模式语义 / C3 不变量 / C4 确定性 / C5 三处 digest 一致 /
C6 B 层扰动可跑 / C7 整数与跨语言口径 / C8 W 产物自足。
C5 唯一跨宿主:S 现算、P 用 node 直接 import core 的 TS 内核现算、W 读
out/mech16_digests.json(未跑则记 PENDING,不算失败)。
跨语言逐位一致的唯一保证:整数运算 + 同算法同种子 PRNG + 同分隔符。
"""
from __future__ import annotations

import json
import os
import subprocess
import sys

import ashlib

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TOP_KEYS = ["spec_version", "id", "name", "seed", "rng", "params", "cards",
            "goals", "invariants", "strategy"]
PARAM_KEYS = ["playerHp", "enemyHp", "enemyAttack", "energyMax", "handSize",
              "turnLimit", "rewardGold", "punishHp"]
CARD_KEYS = ["id", "cost", "damage", "block", "count", "rarity"]
MARKERS = ("function runSpec", "function digestSnap", "function shuffleDeck", "RNG_MUL = 48271")
EXTERNAL = ("http://", "https://", "<link", "<script src")
NODE_HARNESS = (
    'import { runSpec } from "./core/src/ashdeck/combat.ts";'
    'import { digestSnap } from "./core/src/ashdeck/digest.ts";'
    'import { readFileSync } from "node:fs";'
    'const s = JSON.parse(readFileSync("config/play_spec.json", "utf8"));'
    'console.log(digestSnap(runSpec(s)));')


def walk_numbers(x, path="spec"):
    if isinstance(x, dict):
        for k, v in x.items():
            yield from walk_numbers(v, f"{path}.{k}")
    elif isinstance(x, list):
        for i, v in enumerate(x):
            yield from walk_numbers(v, f"{path}[{i}]")
    elif not isinstance(x, bool) and isinstance(x, (int, float)):
        yield path, x


def floats(spec):
    return [p for p, v in walk_numbers(spec) if isinstance(v, float)]


def p_digest_via_node():
    try:
        proc = subprocess.run(["node", "--input-type=module", "-e", NODE_HARNESS],
                              cwd=ROOT, capture_output=True, text=True, timeout=120)
    except (OSError, subprocess.SubprocessError):
        return None
    if proc.returncode != 0 or not proc.stdout.strip():
        return None
    return proc.stdout.strip().splitlines()[-1]


def c1_fields(spec):
    miss = [k for k in TOP_KEYS if k not in spec]
    pmiss = [k for k in PARAM_KEYS if k not in spec.get("params", {})]
    fl = floats(spec)
    return not miss and not pmiss and not fl, f"顶层缺={miss or '无'} params缺={pmiss or '无'} 浮点={fl or '无'}"


def c2_semantic(spec):
    p = spec["params"]
    deck = ashlib.deck_size(spec)
    mx = max(c["cost"] for c in spec["cards"])
    checks = {"deck==12": deck == 12, "maxCost<=energyMax": mx <= p["energyMax"],
              "handSize<=deck": p["handSize"] <= deck, "turnLimit>=1": p["turnLimit"] >= 1,
              "rng==MINSTD": spec["rng"]["mod"] == ashlib.RNG_MOD and spec["rng"]["mul"] == ashlib.RNG_MUL,
              "seed>0": spec["seed"] > 0,
              "cards字段齐": all(all(k in c for k in CARD_KEYS) for c in spec["cards"])}
    bad = [k for k, v in checks.items() if not v]
    return not bad, f"牌数={deck} 最大费用={mx} 能量上限={p['energyMax']} 不满足={bad or '无'}"


def c3_invariants(spec):
    s = ashlib.run_spec(spec)
    p = spec["params"]
    checks = {"牌数守恒": s["cardCount"] == ashlib.deck_size(spec),
              "伤害守恒": s["damageConserved"] is True, "能量在界": 0 <= s["energy"] <= p["energyMax"],
              "格挡非负": s["block"] >= 0, "我方血量在界": 0 <= s["playerHp"] <= p["playerHp"],
              "敌方血量在界": 0 <= s["enemyHp"] <= p["enemyHp"]}
    bad = [k for k, v in checks.items() if not v]
    return not bad, f"不变量 {len(checks)} 条,违反={bad or '无'}"


def c4_determinism(spec):
    d1 = ashlib.digest(ashlib.run_spec(spec))
    d2 = ashlib.digest(ashlib.run_spec(spec))
    other = json.loads(json.dumps(spec))
    other["seed"] = spec["seed"] + 1
    d3 = ashlib.digest(ashlib.run_spec(other))
    return d1 == d2 and d1 != d3, f"两遍相同={d1 == d2} 换种子改变={d1 != d3}"


def c5_three_way(spec):
    py = ashlib.digest(ashlib.run_spec(spec))
    node = p_digest_via_node()
    path = os.path.join(ROOT, "out", "mech16_digests.json")
    wdig = json.load(open(path, encoding="utf-8"))["browserDigest"] if os.path.exists(path) else None
    if node is None and wdig is None:
        return True, "PENDING:P/W 两侧证据均未就绪(先跑 verify_mech16.mjs)", True
    parts = [f"S==P:{node == py if node else '待跑'}"]
    if wdig is not None:
        parts.append(f"S==W:{wdig == py}")
    ok = (node is None or node == py) and (wdig is None or wdig == py)
    return ok, " ".join(parts), False


def c6_perturb(spec):
    base = ashlib.run_spec(spec)
    pert = json.loads(json.dumps(spec))
    pert["params"]["energyMax"] = 2
    ps = ashlib.run_spec(pert)
    changed = ashlib.digest(base) != ashlib.digest(ps)
    inv = ps["cardCount"] == ashlib.deck_size(spec) and ps["damageConserved"] is True \
        and 0 <= ps["energy"] <= 2 and ps["block"] >= 0
    return changed and inv, f"energyMax 3→2 digest改变={changed} 不变量保持={inv}"


def c7_integer(spec):
    keys = ashlib.DIGEST_KEYS
    ok = not floats(spec) and len(keys) == 12 and keys == sorted(keys) and len(set(keys)) == 12
    return ok, f"浮点={floats(spec) or '无'} digest键数={len(keys)} 字母序={keys == sorted(keys)}"


def c8_selfcontained(_spec):
    path = os.path.join(ROOT, "out", "mech16.html")
    if not os.path.exists(path):
        return True, "PENDING:out/mech16.html 未构建", True
    html = open(path, encoding="utf-8").read()
    mk = all(html.count(m) >= 1 for m in MARKERS)
    ext = all(html.count(p) == 0 for p in EXTERNAL)
    return mk and ext, f"内核标记齐={mk} 外部引用0={ext}", False


CHECKS = [("C1", "规格字段完整性", c1_fields), ("C2", "模式与语义校验", c2_semantic),
          ("C3", "不变量清单", c3_invariants), ("C4", "确定性", c4_determinism),
          ("C5", "三处 digest 一致", c5_three_way), ("C6", "B 层扰动可跑", c6_perturb),
          ("C7", "整数与跨语言口径", c7_integer), ("C8", "W 产物自足", c8_selfcontained)]


def main():
    item = sys.argv[sys.argv.index("--item") + 1] if "--item" in sys.argv else None
    spec = ashlib.load_spec()
    rows = []
    for cid, group, fn in CHECKS:
        if item and cid != item:
            continue
        r = fn(spec)
        ok, detail = r[0], r[1]
        pending = bool(r[2]) if len(r) > 2 else False
        rows.append({"id": cid, "group": group, "ok": ok,
                     "status": "PENDING" if pending else ("ok" if ok else "FAIL"), "detail": detail})
        if item:
            print(f"{cid} {group}: {rows[-1]['status']}  {detail}")
            return 0 if ok else 1
    fails = [r for r in rows if r["status"] == "FAIL"]
    pending = [r["id"] for r in rows if r["status"] == "PENDING"]
    print("=== 16.1 规格装配检查表(逐项真跑)===")
    for r in rows:
        print(f"  {r['status']:8s} {r['id']} {r['group']:14s} {r['detail']}")
    print(f"items={len(rows)} fail={len(fails)} pending={len(pending)} pendingIds={pending}")
    with open(os.path.join(ROOT, "out", "gd_mech_16_assembly.json"), "w", encoding="utf-8") as f:
        json.dump({"spec": spec["id"], "checklist": "config/gd_mech_16_checklist.json",
                   "items": rows, "fail": len(fails), "pending": pending},
                  f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print("wrote out/gd_mech_16_assembly.json")
    return 1 if fails else 0


if __name__ == "__main__":
    sys.exit(main())

实跑输出

=== 16.1 规格装配检查表(逐项真跑)===
  ok       C1 规格字段完整性        顶层缺=无 params缺=无 浮点=无
  ok       C2 模式与语义校验        牌数=12 最大费用=2 能量上限=3 不满足=无
  ok       C3 不变量清单          不变量 6 条,违反=无
  ok       C4 确定性            两遍相同=True 换种子改变=True
  ok       C5 三处 digest 一致   S==P:True S==W:True
  ok       C6 B 层扰动可跑        energyMax 3→2 digest改变=True 不变量保持=True
  ok       C7 整数与跨语言口径       浮点=无 digest键数=12 字母序=True
  ok       C8 W 产物自足         内核标记齐=True 外部引用0=True
items=8 fail=0 pending=0 pendingIds=[]
wrote out/gd_mech_16_assembly.json

八项全 ok、pending=0。C2 里 最大费用=2 能量上限=3 说明重牌(费用 2)一回合打得动;C5 的 S==P:True S==W:True 是三方 digest 一致的直接证据;C8 的 外部引用0=True 说明 out/mech16.html 确实自足。

三类检查项的区别

类型项失败意味着能不能自动修
结构类C1 / C2 / C7规格本身写错了(字段缺、浮点混入)能,改 play_spec.json
语义类C3 / C6机制逻辑坏了(守恒破了、扰动后越界)不能,得回 combat.ts 找规则
对照类C4 / C5 / C8某处消费与其它处漂移了分情况,先定位是构建还是口径

八项并不是同一种东西,混在一起会误判失败含义:结构类失败最便宜,改一个字段就好;语义类失败最贵,它说明第 4 章的规则实现出了问题;对照类失败最有价值,因为它恰好是「四处消费」主张的证伪点。

本节实跑清单

命令产物实测
/opt/homebrew/bin/python3 sim/gd_mech_16_assembly.pyout/gd_mech_16_assembly.json8 项全 ok,fail=0 pending=0
... --item C5 / ... --item C6单行结论S==P:True S==W:True;energyMax 3→2 digest改变=True 不变量保持=True

这张表的意义不在于「八项都过了」,而在于它把「都过了」变成了一件可以被机器重复的事。下一节把 C5 展开:不再满足于「三处一致」,而是真的把 S / P / W / B 四处一起跑一遍,并且改一个规则参数看它们是否同步改变。

阅读导航:上一节:15.3 偏差与容差 · 下一节:16.2 四处消费的联调 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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