15.2 六分之六通过闸门
站内 《小游戏开发实战》15.3 交付物验收与交接
是产品立项侧的验收——交付物齐不齐、能不能交接给下一个人;本节的验收是机制侧的——六个品类变体在五条判据上是否全过,而且这个「全过」必须由一次独立重跑重新判出来,不能是把 13.3 的结论抄一遍。13.3 已经判过一轮(allPass: true),但那是它自己那次运行里的结论;15.1 的回归刚证明了「重跑得到的东西和基线一致」,把两件事合起来才能下本节这个判定:重跑六个内核、重新判五条、逐格与 13.3 比对,六条全过、三十格全绿。
权威输入与「重跑 ≠ 照抄」
out/gd_proto_13_gate.json 是权威输入——它定义了判据名单(criteria 五个)、通过阈值(minDistinctBuckets: 2)、契约槽位(contractSlots 十二个)。本节的 P 层读它,但不读它的结论:它自己重新跑一遍 judge(),对每个变体在它自己声明的扰动轴上跑满全部输入(种子轴 16 点,平台跳跃的时机轴 81 点),得到一份新矩阵,再断言「新矩阵的每一格 == 权威输入对应的那一格」。这个区分很关键:照抄是 cells = base.criteria,重跑是 cells = judge(...) 之后再 assert cells === base.criteria——前者永远绿(自己和自己比),后者才可能在基线漂移时报红。所以报告里每一行都带一个 matchesBase 字段,它才是「重跑真的复现了权威输入」的证据。
# snippet: 跑 15.2 的 P 层(写 out/gd_proto_15_gate_matrix.json 与 out/gd_proto_15_robust.json)
node --test 'core/test/gd_proto_15_2_gate_matrix.test.ts'
// file: core/test/gd_proto_15_2_gate_matrix.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_15_2_gate_matrix.test.ts'
//
// 卷三 15.2 六分之六通过闸门(P 层):正式判定 6/6 通过,并给出 **6 变体 × 5 判据 = 30 格**的
// 通过矩阵。**权威输入**是 13.3 的 out/gd_proto_13_gate.json——但本测试不照抄,而是**重跑**六个
// 内核、重新判五条,再断言重跑结果与权威输入逐格一致(重跑 ≠ 照抄)。
// 另落一条 B 层稳健性扫描的**数据**:对扰动幅度做小范围扫描(种子轴扫 N∈{2,4,8,16,32},
// 平台跳跃扫时机幅度 A∈{1,2,3,4,6}),记下每个幅度下「结果桶数 ≥ 2」是否成立——
// 通过状态在多大范围内保持,由 sim/gd_proto_15_2_robust.py 读这份数据判读(软告警)。
// 全整数、无浮点、无 Math.random。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, writeFileSync, mkdirSync } 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 { tdRun, tdInvariantsHold, tdDigest } from "../genre/towerdefense.ts";
import { m3Run, m3InvariantsHold, m3Digest } from "../genre/match3.ts";
import { pfRun, pfInvariantsHold, pfDigest, pfShiftJump } from "../genre/platformer.ts";
import { abRun, abInvariantsHold, abDigest } from "../genre/autobattler.ts";
import { idleRun, idleInvariantsHold, idleDigest } from "../genre/idle.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const OUT = join(ROOT, "out");
const load = (rel: string): any => JSON.parse(readFileSync(join(ROOT, rel), "utf8"));
const clone = <T>(o: T): T => JSON.parse(JSON.stringify(o)) as T;
const REG: any = load("config/gd_proto_13_variants.json");
const BASE: any = load("out/gd_proto_13_gate.json");
const CRIT: string[] = BASE.criteria;
const MINB: number = BASE.minDistinctBuckets;
const SEED_BASE: number = REG.gate.seedAxisBase;
const SEEDS16: number[] = Array.from({ length: REG.gate.seedAxisPoints }, (_, i) => SEED_BASE + i);
const GATES: Record<string, any> = {
deckbuilder: { run: (sp: any) => runSpec(sp), inv: invariantsHold, dig: digestSnap,
terminalOk: (sp: any, s: any) => s.over === true && s.turn <= sp.params.turnLimit },
"tower-defense": { run: (sp: any) => tdRun(sp), inv: tdInvariantsHold, dig: tdDigest,
terminalOk: (sp: any, s: any) => s.over === true && s.ticks < 100000 && s.wave <= sp.params.waveCount },
match3: { run: (sp: any) => m3Run(sp), inv: m3InvariantsHold, dig: m3Digest,
terminalOk: (sp: any, s: any) => s.over === true && s.endDeadlock === false && s.movesUsed <= sp.params.moveCount },
platformer: { run: (sp: any) => pfRun(sp), inv: pfInvariantsHold, dig: pfDigest,
terminalOk: (sp: any, s: any) => s.over === true && s.frame <= sp.params.frameLimit && s.remainingX >= 0 },
autobattler: { run: (sp: any) => abRun(sp), inv: abInvariantsHold, dig: abDigest,
terminalOk: (sp: any, s: any) => s.over === true && s.round <= sp.params.rounds },
idle: { run: (sp: any) => idleRun(sp), inv: idleInvariantsHold, dig: idleDigest,
terminalOk: (sp: any, s: any) => s.over === true && s.tick === sp.params.ticks },
};
/** 种子轴:取前 n 个种子。 */
function seedPoints(raw: any, n: number): any[] {
const out: any[] = [];
for (let i = 0; i < n; i += 1) { const sp = clone(raw); sp.seed = SEED_BASE + i; out.push(sp); }
return out;
}
/** 时机轴:两跳各平移 ±A 帧的笛卡尔积。 */
function timingPoints(raw: any, amp: number): any[] {
const amps = Array.from({ length: 2 * amp + 1 }, (_, k) => k - amp);
const out: any[] = [];
for (const a of amps) for (const b of amps) {
const sp = clone(raw);
let seq = pfShiftJump(sp.strategy, 0, a);
seq = pfShiftJump(seq, 1, b);
sp.strategy = seq;
out.push(sp);
}
return out;
}
function buckets(digests: string[]): number { return new Set(digests).size; }
/** 判五条:把变体在声明轴上的全部输入跑一遍。 */
function judge(genre: string, raw: any, axis: string, points: any[]) {
const g = GATES[genre];
const d1 = g.dig(g.run(clone(raw)));
const d2 = g.dig(g.run(clone(raw)));
const digests: string[] = [];
let invOk = 0, termOk = 0;
for (const sp of points) {
const s = g.run(sp);
digests.push(g.dig(s));
if (g.inv(sp, s)) invOk += 1;
if (g.terminalOk(sp, s)) termOk += 1;
}
const contractOk = JSON.stringify(Object.keys(JSON.parse(d1)).sort()) ===
JSON.stringify(BASE.contractSlots.slice().sort());
return {
determinism: d1 === d2, invariants: invOk === points.length,
terminal_reachable: termOk === points.length, contract_isomorphic: contractOk,
perturbation_spread: buckets(digests) >= MINB,
};
}
const MATRIX: any[] = [];
for (const v of REG.variants) {
const raw = load(v.spec);
const pts = v.axis === "seed" ? seedPoints(raw, SEEDS16.length) : timingPoints(raw, 4);
const cells = judge(v.genre, raw, v.axis, pts);
const base = BASE.variants.find((b: any) => b.genre === v.genre);
MATRIX.push({ genre: v.genre, label: v.label, axis: v.axis, points: pts.length, cells,
baseCells: base.criteria, matchesBase: JSON.stringify(cells) === JSON.stringify(base.criteria),
pass: CRIT.every((c) => cells[c]) });
}
const ROBUST: any[] = [];
for (const v of REG.variants) {
const raw = load(v.spec);
const sweep: any[] = [];
if (v.axis === "seed") {
for (const n of [2, 4, 8, 16, 32]) {
const pts = seedPoints(raw, n);
const b = buckets(pts.map((sp) => GATES[v.genre].dig(GATES[v.genre].run(sp))));
sweep.push({ amplitude: n, points: n, buckets: b, pass: b >= MINB });
}
} else {
for (const amp of [1, 2, 3, 4, 6]) {
const pts = timingPoints(raw, amp);
const b = buckets(pts.map((sp) => GATES[v.genre].dig(GATES[v.genre].run(sp))));
sweep.push({ amplitude: amp, points: pts.length, buckets: b, pass: b >= MINB });
}
}
ROBUST.push({ genre: v.genre, axis: v.axis, sweep });
}
const matrix = {
id: "gd_proto_15_gate_matrix", criteria: CRIT, genres: MATRIX.map((m) => m.genre),
matrix: MATRIX, cellCount: MATRIX.length * CRIT.length,
passedCells: MATRIX.reduce((a, m) => a + CRIT.filter((c) => m.cells[c]).length, 0),
passed: MATRIX.filter((m) => m.pass).length, allPass: MATRIX.every((m) => m.pass),
};
const robust = { id: "gd_proto_15_robust", minDistinctBuckets: MINB, axes: ROBUST };
mkdirSync(OUT, { recursive: true });
writeFileSync(join(OUT, "gd_proto_15_gate_matrix.json"), JSON.stringify(matrix, null, 2) + "\n");
writeFileSync(join(OUT, "gd_proto_15_robust.json"), JSON.stringify(robust, null, 2) + "\n");
test("6×5 通过矩阵:30 格,重跑结果与 13.3 权威输入逐格一致", () => {
assert.equal(matrix.cellCount, 30);
for (const m of MATRIX) assert.equal(m.matchesBase, true, m.genre);
});
test("正式判定:六变体 6/6 通过", () => {
assert.equal(matrix.passed, 6);
assert.equal(matrix.passedCells, 30);
assert.equal(matrix.allPass, true);
});
test("B 层稳健性数据:扰动幅度扫描已落盘", () => {
assert.equal(ROBUST.length, 6);
for (const r of ROBUST) assert.ok(r.sweep.length >= 5, r.genre);
});
test("闸门矩阵与稳健性扫描打印", () => {
console.log(" genre " + CRIT.map((c) => c.slice(0, 4).padStart(5)).join("") + " pass");
for (const m of MATRIX) {
console.log(` ${m.genre.padEnd(16)} ` + CRIT.map((c) => (m.cells[c] ? " ✓ " : " ✗ ")).join("") + " " + (m.pass ? "是" : "否"));
}
console.log(" passed=" + matrix.passed + "/6 cells=" + matrix.passedCells + "/30");
console.log(" 稳健性扫描(桶数 / 幅度):");
for (const r of ROBUST) {
console.log(` ${r.genre.padEnd(16)} ${r.axis.padEnd(7)} ` +
r.sweep.map((s) => s.amplitude + ":" + s.buckets + (s.pass ? "" : "!")).join(" "));
}
});
genre dete inva term cont pert pass
deckbuilder ✓ ✓ ✓ ✓ ✓ 是
tower-defense ✓ ✓ ✓ ✓ ✓ 是
match3 ✓ ✓ ✓ ✓ ✓ 是
platformer ✓ ✓ ✓ ✓ ✓ 是
autobattler ✓ ✓ ✓ ✓ ✓ 是
idle ✓ ✓ ✓ ✓ ✓ 是
passed=6/6 cells=30/30
矩阵的六行是六个变体,五列是五条判据:determinism(确定性)、invariants(不变量)、terminal_reachable(终态可达)、contract_isomorphic(契约同构)、perturbation_spread(扰动非退化)。前四条是「不可能发生某事」型断言,全绿是意料之中(13.3 已证过);真正要盯的是第五列,它是唯一依赖「扰动幅度够不够」的一列。注意各行的 points 并不相同:五个种子轴变体各跑 16 点,平台跳跃的时机轴跑 81 点(9×9)——判据⑤读的是这条轴上的 digest 桶数,所以「多少点」只是判据的输入规模,比「通过与否」合法、比「桶数」不合法。
B 层:扰动幅度扫描(软告警)
判据⑤有个隐蔽的脆弱点:如果扰动幅度太小,桶数会假性塌回 1,判据⑤会假失败;反过来如果幅度太大,也可能掩盖真实的区分度。所以本节加一层 B 层扫描——对每个变体,把扰动幅度从小到大扫一遍,报告「桶数 ≥ 2 这条在多大范围内保持」:
# snippet: 读 out/gd_proto_15_robust.json,判读通过幅度区间(B 层软告警)
/opt/homebrew/bin/python3 sim/gd_proto_15_2_robust.py
#!/usr/bin/env python3
# file: sim/gd_proto_15_2_robust.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_proto_15_2_robust.py
"""卷三 15.2 六分之六通过闸门(B 层 · 稳健性扫描):对**扰动幅度**做小范围扫描,报告
「扰动非退化(结果桶数 ≥ 2)这条判据的通过状态在多大范围内保持」。
数据来自 P 层 out/gd_proto_15_robust.json:种子轴扫 N ∈ {2,4,8,16,32}(取前 N 个种子),
平台跳跃的时机轴扫幅度 A ∈ {1,2,3,4,6}(两跳各平移 ±A 帧)。本脚本只读该数据、判读并**软告警**:
某变体在某个幅度下桶数塌回 1,只打印 WARN,**不**非零退出——B 层是软告警不硬失败。
结论口径:通过状态在「幅度 ≥ 阈值」的范围内保持;幅度太小(种子 N=2、时机 A≤2)会假不通过。
这与 13.3 的经验一致——**扰动幅度必须跨过边界**,否则判据⑤会假通过或假失败。全整数、无浮点。
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ashlib import svg_begin, svg_text, svg_bar, svg_save, write_csv # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
ROBUST = os.path.join(OUT, "gd_proto_15_robust.json")
SVG = os.path.join(OUT, "gd_proto_15_robust.svg")
CSV = os.path.join(OUT, "gd_proto_15_robust.csv")
SUMMARY = os.path.join(OUT, "gd_proto_15_robust_summary.json")
def main() -> int:
with open(ROBUST, encoding="utf-8") as f:
r = json.load(f)
minb = r["minDistinctBuckets"]
warns, summary = [], []
for ax in r["axes"]:
sweep = ax["sweep"]
passing = [s["amplitude"] for s in sweep if s["pass"]]
failing = [s["amplitude"] for s in sweep if not s["pass"]]
holdFrom = min(passing) if passing else None
holdAll = len(failing) == 0
summary.append({"genre": ax["genre"], "axis": ax["axis"], "holdFrom": holdFrom,
"holdAll": holdAll, "failingAmplitudes": failing,
"bucketsByAmplitude": {str(s["amplitude"]): s["buckets"] for s in sweep}})
if failing:
warns.append("%s(%s 轴)在幅度 %s 下桶数塌回 1,幅度 ≥ %s 才保持通过"
% (ax["genre"], ax["axis"], failing, holdFrom))
os.makedirs(OUT, exist_ok=True)
with open(SUMMARY, "w", encoding="utf-8") as f:
json.dump({"id": "gd_proto_15_robust_summary", "minDistinctBuckets": minb,
"axes": summary, "warnings": warns}, f, ensure_ascii=False, indent=2)
f.write("\n")
rows = [[ax["genre"], ax["axis"], s["amplitude"], s["points"], s["buckets"],
"pass" if s["pass"] else "fail"] for ax in r["axes"] for s in ax["sweep"]]
write_csv(CSV, ["genre", "axis", "amplitude", "points", "buckets", "verdict"], rows)
w, h = 760, 70 + len(summary) * 38
root = svg_begin(w, h, "#111827")
svg_text(root, 20, 30, "扰动幅度扫描:桶数保持 ≥ %d 的最大幅度区间(B 层软告警)" % minb, 13)
for i, s in enumerate(summary):
y = 52 + i * 38
svg_text(root, 20, y + 14, "%s(%s)" % (s["genre"], s["axis"]), 12)
svg_bar(root, 210, y, 420, 16, s["holdFrom"] or 0, 32,
"#22c55e" if s["holdAll"] else "#f59e0b",
("幅度 ≥ %d 起保持" % s["holdFrom"]) if s["holdFrom"] else "始终不通过")
size = svg_save(root, SVG)
print("minDistinctBuckets=" + str(minb))
for ax in r["axes"]:
line = " ".join("%d:%d%s" % (s["amplitude"], s["buckets"], "" if s["pass"] else "!") for s in ax["sweep"])
print(" %-14s %-7s %s" % (ax["genre"], ax["axis"], line))
print("保持全通过的变体:%d/%d" % (sum(1 for s in summary if s["holdAll"]), len(summary)))
for s in summary:
print(" %-14s holdFrom=%s holdAll=%s failing=%s"
% (s["genre"], s["holdFrom"], s["holdAll"], s["failingAmplitudes"]))
for wn in warns:
print("WARN " + wn)
print("svg=%d B csv=%d rows summary=out/gd_proto_15_robust_summary.json" % (size, len(rows)))
print("OK B 层扫描完成(软告警,不硬失败)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
minDistinctBuckets=2
deckbuilder seed 2:2 4:4 8:8 16:16 32:31
tower-defense seed 2:1! 4:3 8:4 16:10 32:12
match3 seed 2:2 4:4 8:8 16:16 32:32
platformer timing 1:1! 2:1! 3:2 4:5 6:12
autobattler seed 2:2 4:4 8:8 16:16 32:32
idle seed 2:2 4:4 8:8 16:16 32:32
保持全通过的变体:4/6
deckbuilder holdFrom=2 holdAll=True failing=[]
tower-defense holdFrom=4 holdAll=False failing=[2]
match3 holdFrom=2 holdAll=True failing=[]
platformer holdFrom=3 holdAll=False failing=[1, 2]
autobattler holdFrom=2 holdAll=True failing=[]
idle holdFrom=2 holdAll=True failing=[]
WARN tower-defense(seed 轴)在幅度 [2] 下桶数塌回 1,幅度 ≥ 4 才保持通过
WARN platformer(timing 轴)在幅度 [1, 2] 下桶数塌回 1,幅度 ≥ 3 才保持通过
svg=2922 B csv=30 rows summary=out/gd_proto_15_robust_summary.json
OK B 层扫描完成(软告警,不硬失败)
两个 WARN 是本层最有价值的产出。塔防在种子幅度 2 时桶数塌回 1,幅度 ≥ 4 才保持通过;平台跳跃在时机幅度 1、2 时桶数塌回 1,幅度 ≥ 3 才保持通过。这不是「变体坏了」——是扰动幅度太小,与 13.3 记下的那条经验(平台跳跃 ±1 帧全在容差内、必须跨过 pfReqTol = 2 的可解边界)一致。B 层把「通过」从一个布尔值升级成一句带前提的话——「在幅度 ≥ 阈值时通过」;整层只打印 WARN、退出码仍是 0,它报告的是一个区间,不是一个判定。
小结
15.2 的结论是 6/6 通过、30/30 格全绿,每一格都重跑内核重新判出、再与 13.3 权威输入逐格比对过(matchesBase=6/6);B 层补上了判据⑤的前提。但「6/6 通过」这句话还有三处保留没交代——下一节把它们逐条摊开。
阅读导航:上一节:15.1 六品类变体的回归 · 下一节:15.3 失败的诚实报告 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。