10.2 不变量清单

第 1、3、4 章各散落着一部分不变量:资源边界、守恒、状态封闭。本节把它们收成一张 8 条的可执行清单,逐条给出整数判据与一个故意造违反的负例。清单在真实轨迹的 26 个状态上全 true,8 条负例全被判 false——把「机制自洽」从零散断言变成可回归的验收面。

SLG 模块拆分 列的是「系统有哪些模块」这种结构清单;本节列的是「哪些状态永远不允许存在」这种语义清单。前者回答系统怎么分,后者回答规则对不对。前几章的不变量是分散的:1.2 讲资源与目标时定了血量与能量的边界,3.2 讲守恒时给了四条误差判据,4.2 讲断言时把它们分成守恒、不可达、单调三类。分散不是错,但验收需要一张能逐条打勾的表——本节就把它汇成一张可执行清单。

10.2 不变量清单

清单里的每一条都是一个纯函数:吃一个快照,吐一个布尔。判定用整数比较,不含任何容差——「守恒」在这里的判据是「差恰好为 0」,不是「差很小」。8 条覆盖三处来源,逐条配一个故意造违反的负例:

#不变量来源判据(整数)故意造的反例
1cardCount==deckSize3.2三区之和 === 12cardCount = 11
2damageConserved3.2dealt === enemyHp₀ − enemyHpdealt + 1
3energyInRange3.20 ≤ energy ≤ energyMaxenergy = energyMax + 1
4blockNonNegative3.2block ≥ 0block = −1
5hpNotExceed1.2playerHp ≤ playerHp₀ 且 enemyHp ≤ enemyHp₀enemyHp = enemyHp₀ + 1
6goalConsistency1.2won ⇒ enemyHp ≤ 0won=true 且 enemyHp=10
7turnBound1.2over ∨ turn ≤ turnLimitover=false 且 turn=turnLimit+1
8handClosed4.1hand ⊆ 卡池 id 集合hand=["heal"]

清单和 4.2 的 invariantsHold 是子集关系,不是替代。invariantsHold 把第 1–4 条压成一个布尔,是内核对外的自陈;清单多了第 5–8 条,并且逐条指名——破了哪一条、差在哪,清单说得比一个总布尔清楚。第 5–7 条来自 1.2 的目标与边界:血量上界、胜负一致、回合上界,它们约束的是目标层而不是资源层。第 8 条来自 4.1 的状态封闭:手牌里的每张牌都必须在卡池里,这是「状态集合有穷」能成立的隐含前提——一旦手牌出现池外的 id,第 1 条的牌数守恒也可能被假象掩盖。

按检测形式再分一次类,8 条其实只有四种,各自的失效方式不同:

形式条目检测式失效方式
等式1、2x === y两边一起错时漏判(三区与 deckSize 同时被改)
区间3、5lo ≤ x ≤ hi区间被放宽成「约等于」时失去意义
蕴含6、7p ⇒ q前件恒假时该条退化为恒真
子集8hand ⊆ pool卡池没随新卡同步更新时误报

四种里最容易被糊弄的是蕴含。第 6 条 won ⇒ enemyHp ≤ 0,如果 won 永远是 false,这条恒真、毫无信息量——所以它的反例必须真正触发后件(won=true 且 enemyHp>0)。第 7 条同理,反例要造 over=false 且 turn 越界,而不是随手取一个已经结束的状态。

清单约束的是状态,不是步。4.2 普查出规格自带策略里有 13 个空转步(4 个欠费、9 个终局后),但在那 13 步上清单仍然逐条成立——因为空转步不改变状态,而不变量说的是「任何可达状态都合法」,不是「每一步都必须做事」。这两件事要分清:前者是安全性(safety,坏事永不发生),后者是活性(liveness,好事终将发生)。清单管安全,不管活性;活性由 10.3 的可解性搜索兜底。

清单在轨迹上的样子

把 8 条判据在 25 步轨迹的若干个截面上并排打印,一张命中矩阵就把「逐条成立」从一句话变成可读的证据:

step card dmg eng blk hp goal turn hand note
   0 T T T T T T T T 开局
   1 T T T T T T T T
   4 T T T T T T T T 第4步欠费空转
   8 T T T T T T T T
  16 T T T T T T T T 第16步取胜
  25 T T T T T T T T 终态

四行值得逐行读。开局(step 0)也全 T——「还没动」不是免检状态,cardCount===12、dealt===0、enemyHp===50 在这里就成立。第 4 步是欠费空转,状态与第 3 步逐字相同,清单照旧全 T:它顺带证明空转步不破坏不变量。第 16 步取胜后 won 置位、enemyHp 归零,第 6 条 goal 仍 T。终态(step 25)全 T,说明 9 个终局后的空转步也没让任何一条失效。

矩阵里全是 T,看起来「没抓到什么」——但这正是安全性清单的常态:它证明的是「坏事从没发生」,不是「抓到了坏事」。要让这张表出现 F,得喂一个人为破坏的快照,那正是脚本里 8 条负例在做的事。

清单与 3.2 的误差表是同一件事的两种粒度。3.2 的误差表输出「差多少」(cardErr、dmgErr…),适合排障定位;清单输出「破没破」(布尔),适合当门禁。两者的判据完全一致——3.2 全 0 等价于清单全 T。实践里两者都要:CI 里跑布尔清单当红灯,排障时跑误差表看差值。把它们当成两套东西,就会出现「清单全绿但误差表在叫」的尴尬——那只能说明清单的判据比误差表松,得回去改清单。

下面这段是 P 层的清单验收脚本。它把 8 条检查写成数组,在真实轨迹上逐条查,再对每一条喂一个反例:

// file: core/test/gd_mech_10_checklist.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_10_checklist.test.ts'
//
// 10.2 不变量清单:把 ch1(资源与目标)/ ch3(守恒)/ ch4(状态封闭与断言)
// 散落的不变量汇总成**一张可执行清单**。每条检查是一个 (snapshot) -> boolean
// 的纯函数;清单在真实轨迹上必须全 true,且**每条都配一个故意造违反的负例**,
// 检查器必须返回 false——否则这条检查只是摆设(4.2 已说过:没有反面的断言等于没说)。

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

import { runSpec, invariantsHold } from "../src/ashdeck/combat.ts";
import type { Snapshot, Spec } from "../src/ashdeck/types.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const SPEC: Spec = JSON.parse(
  readFileSync(join(HERE, "..", "..", "config", "play_spec.json"), "utf8"),
);
const P = SPEC.params;
const DECK = SPEC.cards.reduce((a, c) => a + c.count, 0);
const POOL = new Set(SPEC.cards.map((c) => c.id));

/** 清单一条:名字 + 来源节号 + 判据(整数比较,无浮点)。 */
type Check = { name: string; from: string; ok: (s: Snapshot) => boolean };

const CHECKS: Check[] = [
  { name: "cardCount==deckSize", from: "3.2", ok: (s) => s.cardCount === DECK },
  { name: "damageConserved", from: "3.2", ok: (s) => s.dealt === P.enemyHp - s.enemyHp },
  { name: "energyInRange", from: "3.2", ok: (s) => s.energy >= 0 && s.energy <= P.energyMax },
  { name: "blockNonNegative", from: "3.2", ok: (s) => s.block >= 0 },
  { name: "hpNotExceed", from: "1.2", ok: (s) => s.playerHp <= P.playerHp && s.enemyHp <= P.enemyHp },
  { name: "goalConsistency", from: "1.2", ok: (s) => !s.won || s.enemyHp <= 0 },
  { name: "turnBound", from: "1.2", ok: (s) => s.over || s.turn <= P.turnLimit },
  { name: "handClosed", from: "4.1", ok: (s) => s.hand.every((id) => POOL.has(id)) },
];

/** 逐步轨迹:第 k 项 = 处理完前 k 个命令后的状态。 */
function trace(spec: Spec, seq: string[]): Snapshot[] {
  const out: Snapshot[] = [];
  for (let k = 0; k <= seq.length; k += 1) out.push(runSpec(spec, seq.slice(0, k)));
  return out;
}
const TRACE = trace(SPEC, SPEC.strategy);

test("清单结构:8 条,来源恰好覆盖 1.2 / 3.2 / 4.1", () => {
  assert.equal(CHECKS.length, 8);
  assert.deepEqual([...new Set(CHECKS.map((c) => c.from))].sort(), ["1.2", "3.2", "4.1"]);
});

test("清单在真实轨迹(26 个状态)上逐条成立", () => {
  assert.equal(TRACE.length, 26);
  for (const c of CHECKS) {
    for (const s of TRACE) assert.equal(c.ok(s), true, `${c.name} 在真实状态上破了`);
  }
});

test("负例自证:8 条各有反例,检查器必须判 false", () => {
  const base = TRACE[TRACE.length - 1];
  const bad: Array<[string, Snapshot]> = [
    ["cardCount==deckSize", { ...base, cardCount: DECK - 1 }],
    ["damageConserved", { ...base, dealt: base.dealt + 1 }],
    ["energyInRange", { ...base, energy: P.energyMax + 1 }],
    ["blockNonNegative", { ...base, block: -1 }],
    ["hpNotExceed", { ...base, enemyHp: P.enemyHp + 1 }],
    ["goalConsistency", { ...base, won: true, enemyHp: 10 }],
    ["turnBound", { ...base, over: false, turn: P.turnLimit + 1 }],
    ["handClosed", { ...base, hand: ["heal"] }],
  ];
  assert.equal(bad.length, CHECKS.length);
  for (const [name, snap] of bad) {
    const c = CHECKS.find((x) => x.name === name);
    assert.ok(c, `清单里没有 ${name}`);
    assert.equal(c.ok(snap), false, `${name} 的反例没被抓住`);
  }
  // 恒真检查器必然漏掉全部反例——这就是「每条必须配反例」的理由
  const alwaysTrue = (_s: Snapshot) => true;
  assert.equal(bad.filter(([, s]) => alwaysTrue(s)).length, bad.length);
});

test("与 4.2 的 invariantsHold 对齐:真实轨迹全 true", () => {
  for (const s of TRACE) assert.equal(invariantsHold(SPEC, s), true);
});

实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_10_checklist.test.ts',本节 4 条的真实输出:

✔ 清单结构:8 条,来源恰好覆盖 1.2 / 3.2 / 4.1 (0.824167ms)
✔ 清单在真实轨迹(26 个状态)上逐条成立 (0.171584ms)
✔ 负例自证:8 条各有反例,检查器必须判 false (0.15975ms)
✔ 与 4.2 的 invariantsHold 对齐:真实轨迹全 true (0.091125ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 197.773792

读出来:清单在 26 个状态(开局 + 25 步)上逐条全 true,8 条负例逐条被判 false。最后一条 alwaysTrue 是清单方法论的注脚——一个恒返回 true 的检查器会通过全部正例,却漏掉全部反例;所以「配反例」不是锦上添花,而是清单里每一条存在的证明。这与 4.2 的做法一脉相承:那一节给三类不变量各配一个负例,本节把它推广到清单的每一条。

清单不是凭空列的,每一条都能指回一行规则——这让「改了哪条规则」直接映射到「复查哪条清单」:

#不变量约束的字段由哪行规则保证
1cardCount==deckSizecardCount三区搬运(hand / draw / discard)
2damageConserveddealtd = Math.min(c.damage, enemyHp); dealt += d
3energyInRangeenergyenergy -= c.cost 的前置 + energy = p.energyMax
4blockNonNegativeblockMath.min(block, p.enemyAttack)
5hpNotExceedplayerHp / enemyHp无治疗牌 + Math.min 封顶
6goalConsistencywonif (enemyHp <= 0) won = true
7turnBoundturn / overif (turn > turnLimit) over = true
8handClosedhandbuildDeck 只从 spec.cards 生成

反例怎么造

清单的负例不是随便改个字段,每条反例都要精确触发它对应判据的失败分支。8 条反例的构造与命中点如下:

#反例构造命中哪条判据
1cardCount: 11等式两侧不等
2dealt: dealt + 1dealt !== enemyHp₀ − enemyHp
3energy: energyMax + 1超上界
4block: -1下界为负
5enemyHp: enemyHp₀ + 1超 enemyHp₀
6won: true, enemyHp: 10蕴含后件假
7over: false, turn: 21回合越界
8hand: ["heal"]池外 id

反例的构造有一个反直觉之处:它们全都建立在真实终态 base 之上,只改一个字段。这样做的理由是隔离——如果反例同时改了多个字段,判据判 false 时你分不清是哪一处触发的。一次只改一个,才能保证「这条判据真的盯着这个字段」。脚本里那条 alwaysTrue 断言把这件事说透:一个恒返回 true 的检查器能通过全部正例,却在 8 条反例上全部漏判——它存在的唯一作用,就是提醒你「正例全过」不等于「检查有效」。

清单不能无限长。每加一条不变量,就多一份维护成本,也多一次「来源脱节」的机会。经验阈值是:清单条数应显著少于规则的可枚举错类数——本规则 4 种卡、2 条转移分支,8 条清单已经覆盖到目标层与状态封闭,再加就得先问「这条错误真的会发生吗」。一条永不失败的判据(比如前件恒假)留在清单里,只会稀释信号:它每次都亮绿灯,却从没拦下过任何东西。清单的价值不在条数多,而在每一条都咬得住一个具体的错。

与相邻章节的分工

章节管什么与清单的关系
3.2误差表(差多少)同一判据的连续粒度,排障用
4.2invariantsHold(四条合成布尔)清单的子集,内核对外的自陈
10.1可达集(能走到哪)互补:可达集给范围,清单给合法性
10.3死锁与可解性(整图通不通)互补:清单管安全,它管活性

清单会怎么烂掉

清单是活的验收面,它会随机制改动作废。三种常见的烂法,每一种都有对应的自检:

烂法症状自检
判据过宽把 === 改成「差不超过 1」,破了也不报判据必须是整数精确比较
反例缺失新增一条只写了正例,检查器恒 true每条必须有反例,且反例必须被判 false
来源脱节改了规则却不改清单,清单与实现对不上每条标明来源节号,改哪节就复查哪条

第三种最隐蔽。加一张治疗牌时,第 5 条的 playerHp ≤ playerHp₀ 与第 2 条的 damageConserved 都会失效,但脚本不会自己告诉你——它只会照常跑、照常「全绿」,因为清单还是旧的。唯一的办法是把来源写进清单:from: "1.2" 这样的字段让「改了哪一节」直接映射到「复查哪几条」,上表右列就是这张映射的落地。

检查项合格标准
来源可追溯每条标明来自哪一节(1.2 / 3.2 / 4.1)
判据是整数全部为整数等式或不等式,不含容差
逐条有反例8 条各配一个故意造违反的快照,检查器必须判 false
覆盖正例轨迹在开局状态与每一步上都查,不只查终态
与内核对齐清单与 4.2 的 invariantsHold 在同一轨迹上结论一致
安全活性分离清单只管状态合法性,不把「每一步都得做事」混进来

清单回答的是「单个状态合不合法」。它管不到的是「整张状态图通不通」——有没有走不出去的死角、有没有怎么打都赢不了的局面。下一节把这两件事变成自动检测。

阅读导航:上一节:10.1 状态的可达性 · 下一节:10.3 死锁与可解性检测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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