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(浏览器) 逐字相同 |
| C6 | B 层扰动可跑 | 第 6 章 | 覆盖 energyMax=2 后 digest 改变且不变量保持 |
| C7 | 整数与跨语言口径 | 第 11 章 | 无浮点、digest 固定 12 键且字母序 |
| C8 | W 产物自足 | 第 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.py | out/gd_mech_16_assembly.json | 8 项全 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 四处消费的联调 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。