15.1 六品类变体的回归
站内 《小游戏开发实战》12.2 可玩性度量测试 量的是一类分享型指标——抽样多少次、置信区间多宽,回答的是「玩家觉得好不好玩」;本节量的是机制侧的回归——同一套规格在改动前后是否还自洽,回答的是「结果有没有漂移」。两件事都要「重跑一遍再比对」,但 12.2 的比对对象是玩家的回答分布,本节的比对对象是内核产出的 digest。
13 章把六个变体逐一实例化并判了闸门,14 章把它们变成向量、算成距离矩阵。到这一节,六个变体已经各自跑过很多遍,但没有任何一处证明「它们跑出来的东西是稳定的」。回归要补的正是这一环:把 13.3 的闸门报告冻结成基线,然后重跑,逐项断言「和基线一样」。
回归的定义:稳定性,而非正确性
先说清一个容易混淆的边界。回归测试不判断对错,只判断变化。如果某个变体从第一版起就是错的,回归会一直把它判「和基线一致」——它保护的是「没被无意改动」,不是「本来就是对的」。正确性由 13.3 的五条判据负责(它们是「不可能发生某事」型断言),回归负责的是在两次运行之间,那五条判据的结论有没有动。
据此把回归拆成三层,一层比一层接近语义:
| 层 | 断言的对象 | 报红意味着 |
|---|---|---|
| ① 结构回归 | 六变体的规格骨架是否同构:segments、naming 槽位数、state/rules/params/invariant 四段是否都在 | 某个变体偷偷加了字段或丢了段——规格不再同构 |
| ② 数值回归 | 六条 digest 是否与 13.3 冻结的那条逐字相同 | 内核的行为变了(哪怕判据还全绿) |
| ③ 判据回归 | 五条闸门判据的通过状态是否与基线逐条相同 | 判定口径本身漂了 |
三层里,②最敏感——它比的是整条 digest 字符串,任何一个字段动一格都会报红;①最宽,只比骨架;③居中,比的是布尔值。三层都绿才算回归通过,任何一层红就停。
P 层:一套回归,跑三层
回归套件本身就是一个测试文件。它读 out/gd_proto_13_gate.json 当基线,对六个变体各重跑一次,把三层结果写进 out/gd_proto_15_regression.json,最后跑一个负例:
# snippet: 跑 15.1 的 P 层回归(写 out/gd_proto_15_regression.json)
node --test 'core/test/gd_proto_15_1_regression.test.ts'
// file: core/test/gd_proto_15_1_regression.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_15_1_regression.test.ts'
//
// 卷三 15.1 六品类变体的回归(P 层):把 13.3 的闸门报告 out/gd_proto_13_gate.json 当**基线**,
// 对 6 个变体**重跑**,断言结果不变——回归 = 稳定性。三层:① 结构回归(六变体规格骨架同构)
// ② 数值回归(六条 digest 逐字相同)③ 判据回归(五条闸门判据的通过状态不变)。负例:本体
// params.enemyHp 加 1,回归必须报红(证明回归非恒真)。落 out/gd_proto_15_regression.json。
// 全整数、无浮点、无 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 SEGMENTS: string[] = REG.segments;
const SEEDS: number[] = Array.from({ length: REG.gate.seedAxisPoints }, (_, i) => REG.gate.seedAxisBase + i);
const AMP = 4;
const GATES: Record<string, any> = {
deckbuilder: { run: (sp: any) => runSpec(sp), inv: invariantsHold, dig: digestSnap,
term: (sp: any, s: any) => s.over === true && s.turn <= sp.params.turnLimit },
"tower-defense": { run: (sp: any) => tdRun(sp), inv: tdInvariantsHold, dig: tdDigest,
term: (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,
term: (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,
term: (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,
term: (sp: any, s: any) => s.over === true && s.round <= sp.params.rounds },
idle: { run: (sp: any) => idleRun(sp), inv: idleInvariantsHold, dig: idleDigest,
term: (sp: any, s: any) => s.over === true && s.tick === sp.params.ticks },
};
function perturb(raw: any, axis: string, i: number): any {
const sp = clone(raw);
if (axis === "seed") { sp.seed = SEEDS[i]; return sp; }
const amps = Array.from({ length: 2 * AMP + 1 }, (_, k) => k - AMP);
const a = amps[Math.floor(i / amps.length)], b = amps[i % amps.length];
let seq = pfShiftJump(sp.strategy, 0, a);
seq = pfShiftJump(seq, 1, b);
sp.strategy = seq;
return sp;
}
function resolve(spec: any, view: any): any {
const out: any = {};
for (const seg of SEGMENTS) {
const r = view[seg];
if (r.kind === "path") out[seg] = spec[r.path];
else if (r.kind === "identity") { out[seg] = {}; for (const s of REG.naming_slots) out[seg][s] = s; }
else if (r.kind === "derived") { out[seg] = {}; for (const f of r.fields) out[seg][f] = spec[r.from][f]; }
else { out[seg] = {}; for (const k of r.from) out[seg][k] = spec[k]; }
}
return out;
}
function recompute(genre: string, raw: any, axis: string) {
const g = GATES[genre];
const pts = axis === "seed" ? SEEDS.length : (2 * AMP + 1) ** 2;
const d1 = g.dig(g.run(clone(raw)));
const d2 = g.dig(g.run(clone(raw)));
const ds: string[] = [];
let invOk = 0, termOk = 0;
for (let i = 0; i < pts; i += 1) {
const sp = perturb(raw, axis, i);
const s = g.run(sp);
ds.push(g.dig(s));
if (g.inv(sp, s)) invOk += 1;
if (g.term(sp, s)) termOk += 1;
}
const buckets = new Set(ds).size;
const contractOk = JSON.stringify(Object.keys(JSON.parse(d1)).sort()) ===
JSON.stringify(BASE.contractSlots.slice().sort());
return { digest: d1, criteria: { determinism: d1 === d2, invariants: invOk === pts,
terminal_reachable: termOk === pts, contract_isomorphic: contractOk,
perturbation_spread: buckets >= BASE.minDistinctBuckets } };
}
const STRUCT: any[] = [], NUM: any[] = [], CRIT: any[] = [];
for (const v of REG.variants) {
const raw = load(v.spec);
const view = resolve(raw, v.view);
const segs = Object.keys(view);
STRUCT.push({ genre: v.genre, segments: segs, namingSlots: Object.keys(view.naming).length,
ok: JSON.stringify(segs) === JSON.stringify(SEGMENTS) &&
Object.keys(view.naming).length === REG.naming_slots.length &&
["state", "rules", "params", "invariant"].every((s) => Object.keys(view[s]).length >= 1) });
const b = BASE.variants.find((x: any) => x.genre === v.genre);
const now = recompute(v.genre, raw, v.axis);
NUM.push({ genre: v.genre, baseline: b.determinism.digest, now: now.digest,
match: b.determinism.digest === now.digest });
CRIT.push({ genre: v.genre, baseline: b.criteria, now: now.criteria,
match: JSON.stringify(b.criteria) === JSON.stringify(now.criteria) });
}
const db = load("config/play_spec.json");
const mut = clone(db); mut.params.enemyHp += 1;
const negNow = GATES.deckbuilder.dig(GATES.deckbuilder.run(mut));
const negBase = BASE.variants.find((x: any) => x.genre === "deckbuilder").determinism.digest;
const NEG = { genre: "deckbuilder", param: "params.enemyHp", from: db.params.enemyHp, to: mut.params.enemyHp,
baselineDigest: negBase, mutatedDigest: negNow, detected: negBase !== negNow };
const report = { id: "gd_proto_15_regression", baseline: "out/gd_proto_13_gate.json",
layers: ["structure", "numeric", "criteria"], structure: STRUCT, numeric: NUM, criteria: CRIT, negative: NEG,
structureOk: STRUCT.every((r) => r.ok), numericOk: NUM.every((r) => r.match), criteriaOk: CRIT.every((r) => r.match),
regressionPass: STRUCT.every((r) => r.ok) && NUM.every((r) => r.match) && CRIT.every((r) => r.match) };
mkdirSync(OUT, { recursive: true });
writeFileSync(join(OUT, "gd_proto_15_regression.json"), JSON.stringify(report, null, 2) + "\n");
test("① 结构回归:六变体的规格骨架同构", () => {
assert.equal(STRUCT.length, 6);
for (const r of STRUCT) { assert.equal(r.ok, true, r.genre); assert.deepEqual(r.segments, SEGMENTS, r.genre); }
});
test("② 数值回归:六条 digest 与 13.3 基线逐字相同", () => {
for (const r of NUM) assert.equal(r.match, true, r.genre + " digest 漂移");
});
test("③ 判据回归:五条闸门判据的通过状态与基线逐条相同", () => {
for (const r of CRIT) assert.equal(r.match, true, r.genre + " 判据漂移");
});
test("负例:本体 params.enemyHp 加 1,回归必须报红", () => {
assert.equal(NEG.detected, true);
assert.notEqual(NEG.baselineDigest, NEG.mutatedDigest);
});
test("回归结论:三层全绿", () => {
console.log(" layer pass");
console.log(" structure " + (report.structureOk ? "6/6" : "FAIL"));
console.log(" numeric " + (report.numericOk ? "6/6" : "FAIL"));
console.log(" criteria " + (report.criteriaOk ? "6/6" : "FAIL"));
console.log(" negative enemyHp " + NEG.from + "->" + NEG.to + " detected=" + NEG.detected);
assert.equal(report.regressionPass, true);
});
layer pass
structure 6/6
numeric 6/6
criteria 6/6
negative enemyHp 50->51 detected=true
三层都绿,负例也如期报红。这里有两个实现细节值得点出。第一,recompute() 对每个变体都重跑了它的完整扰动轴(种子轴 16 点,平台跳跃的时机轴 81 点),而不是只跑一次默认输入——因为 13.3 的判据⑤(扰动非退化)本来就依赖整条轴,只跑一次根本判不出它。第二,结构回归里那段 resolve() 是照 13.1 的 view 描述把规格投影成六段骨架的,它复用的是注册表声明的投影规则,不是手写的字段名单——所以结构回归和 13.1 的「结构同构」用的是同一套口径。
S 层:把三层回归读成一张记分卡
P 层写下的 out/gd_proto_15_regression.json 是机器产物,还得有一个独立读它的人——否则回归自己写错了也没人知道。S 层只读 JSON、只写 out/**,把三层逐条打印出来并画一张记分卡:
# snippet: 读 out/gd_proto_15_regression.json,复算三层并画记分卡
/opt/homebrew/bin/python3 sim/gd_proto_15_1_regression_report.py
baseline=out/gd_proto_13_gate.json layers=structure|numeric|criteria
structure=6/6 numeric=6/6 criteria=6/6 regressionPass=True
结构 deckbuilder segments=6 naming=8 ok=True
结构 tower-defense segments=6 naming=8 ok=True
结构 match3 segments=6 naming=8 ok=True
结构 platformer segments=6 naming=8 ok=True
结构 autobattler segments=6 naming=8 ok=True
结构 idle segments=6 naming=8 ok=True
数值 deckbuilder match=True
数值 tower-defense match=True
数值 match3 match=True
数值 platformer match=True
数值 autobattler match=True
数值 idle match=True
判据 deckbuilder match=True
判据 tower-defense match=True
判据 match3 match=True
判据 platformer match=True
判据 autobattler match=True
判据 idle match=True
负例 deckbuilder params.enemyHp 50→51 detected=True
svg=2429 B csv=18 rows
OK 三层回归全绿,负例已报红
segments=6 naming=8 里的 8 是 naming_slots 的长度(turn / energy / playerHp / enemyHp / block / played / hand / cardCount)——六个变体的骨架都是这六段、八个命名槽,这正是「同构」在报告里的样子。S 层复算的是报告自身的一致性:三层各自的 match/ok 是否与总括的 structureOk/numericOk/criteriaOk 对得上、regressionPass 是否是三者的与。任何一条不自洽就报 FAIL 并以非零码退出。
负例:回归必须能报红
一个只会打印「全绿」的回归是没用的——它可能压根没在比对。所以套件里钉死了一个负例:把本体 config/play_spec.json 的 params.enemyHp 从 50 改成 51,再算一次 digest,断言它与基线不同。实测 enemyHp 50->51 detected=true:改动确实被数值回归抓到,说明②号层真的在比 digest,而不是恒真。
选 enemyHp 当负例的突变点是有讲究的:它是本体终局判定(enemyHp 降到 0 即胜)直接读的字段,改一格必然改变整局走向;而如果拿一个终局不读的字段去突变,回归可能照样报「一致」——那不能说明回归有效,只能说明那个字段不影响结果。负例要选一个「一定该被察觉」的突变,这是负例设计里最容易做错的地方。
实跑清单
| 层 | 命令 | 结果 |
|---|---|---|
| P | node --test 'core/test/gd_proto_15_1_regression.test.ts' | 5 测试全绿;三层 6/6;负例 detected=true;落 out/gd_proto_15_regression.json |
| S | /opt/homebrew/bin/python3 sim/gd_proto_15_1_regression_report.py | 三层自洽;记分卡 svg 2429 B / csv 18 行 |
小结
15.1 把「六个变体是否稳定」变成了一套可复跑的回归:三层断言、一条冻结基线、一个必然报红的负例。回归 = 稳定性,不是正确性——它保护的是「没被无意改动」,把「对不对」留给 13.3 的五条判据。下一节把这套回归的输出并进闸门,正式给六个变体下「6/6 通过」的判定。
阅读导航:上一节:14.3 距离的读法与边界 · 下一节:15.2 六分之六通过闸门 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。