15.1 六品类变体的回归

把 13.3 的闸门报告当基线、把 14 章的距离口径并进来,合成一套回归套件,对六个品类变体重跑一遍并断言结果不变——回归 = 稳定性,不是正确性。三层:结构回归(六变体规格骨架同构)、数值回归(六条 digest 逐字相同)、判据回归(五条闸门判据的通过状态不变),并附一个负例证明回归不是恒真。

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 即胜)直接读的字段,改一格必然改变整局走向;而如果拿一个终局不读的字段去突变,回归可能照样报「一致」——那不能说明回归有效,只能说明那个字段不影响结果。负例要选一个「一定该被察觉」的突变,这是负例设计里最容易做错的地方。

实跑清单

层命令结果
Pnode --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 六分之六通过闸门 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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