SLG 模块拆分 列的是「系统有哪些模块」这种结构清单;本节列的是「哪些状态永远不允许存在」这种语义清单。前者回答系统怎么分,后者回答规则对不对。前几章的不变量是分散的:1.2 讲资源与目标时定了血量与能量的边界,3.2 讲守恒时给了四条误差判据,4.2 讲断言时把它们分成守恒、不可达、单调三类。分散不是错,但验收需要一张能逐条打勾的表——本节就把它汇成一张可执行清单。
10.2 不变量清单
清单里的每一条都是一个纯函数:吃一个快照,吐一个布尔。判定用整数比较,不含任何容差——「守恒」在这里的判据是「差恰好为 0」,不是「差很小」。8 条覆盖三处来源,逐条配一个故意造违反的负例:
| # | 不变量 | 来源 | 判据(整数) | 故意造的反例 |
|---|---|---|---|---|
| 1 | cardCount==deckSize | 3.2 | 三区之和 === 12 | cardCount = 11 |
| 2 | damageConserved | 3.2 | dealt === enemyHp₀ − enemyHp | dealt + 1 |
| 3 | energyInRange | 3.2 | 0 ≤ energy ≤ energyMax | energy = energyMax + 1 |
| 4 | blockNonNegative | 3.2 | block ≥ 0 | block = −1 |
| 5 | hpNotExceed | 1.2 | playerHp ≤ playerHp₀ 且 enemyHp ≤ enemyHp₀ | enemyHp = enemyHp₀ + 1 |
| 6 | goalConsistency | 1.2 | won ⇒ enemyHp ≤ 0 | won=true 且 enemyHp=10 |
| 7 | turnBound | 1.2 | over ∨ turn ≤ turnLimit | over=false 且 turn=turnLimit+1 |
| 8 | handClosed | 4.1 | hand ⊆ 卡池 id 集合 | hand=["heal"] |
清单和 4.2 的 invariantsHold 是子集关系,不是替代。invariantsHold 把第 1–4 条压成一个布尔,是内核对外的自陈;清单多了第 5–8 条,并且逐条指名——破了哪一条、差在哪,清单说得比一个总布尔清楚。第 5–7 条来自 1.2 的目标与边界:血量上界、胜负一致、回合上界,它们约束的是目标层而不是资源层。第 8 条来自 4.1 的状态封闭:手牌里的每张牌都必须在卡池里,这是「状态集合有穷」能成立的隐含前提——一旦手牌出现池外的 id,第 1 条的牌数守恒也可能被假象掩盖。
按检测形式再分一次类,8 条其实只有四种,各自的失效方式不同:
| 形式 | 条目 | 检测式 | 失效方式 |
|---|---|---|---|
| 等式 | 1、2 | x === y | 两边一起错时漏判(三区与 deckSize 同时被改) |
| 区间 | 3、5 | lo ≤ x ≤ hi | 区间被放宽成「约等于」时失去意义 |
| 蕴含 | 6、7 | p ⇒ q | 前件恒假时该条退化为恒真 |
| 子集 | 8 | hand ⊆ 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 的做法一脉相承:那一节给三类不变量各配一个负例,本节把它推广到清单的每一条。
清单不是凭空列的,每一条都能指回一行规则——这让「改了哪条规则」直接映射到「复查哪条清单」:
| # | 不变量 | 约束的字段 | 由哪行规则保证 |
|---|---|---|---|
| 1 | cardCount==deckSize | cardCount | 三区搬运(hand / draw / discard) |
| 2 | damageConserved | dealt | d = Math.min(c.damage, enemyHp); dealt += d |
| 3 | energyInRange | energy | energy -= c.cost 的前置 + energy = p.energyMax |
| 4 | blockNonNegative | block | Math.min(block, p.enemyAttack) |
| 5 | hpNotExceed | playerHp / enemyHp | 无治疗牌 + Math.min 封顶 |
| 6 | goalConsistency | won | if (enemyHp <= 0) won = true |
| 7 | turnBound | turn / over | if (turn > turnLimit) over = true |
| 8 | handClosed | hand | buildDeck 只从 spec.cards 生成 |
反例怎么造
清单的负例不是随便改个字段,每条反例都要精确触发它对应判据的失败分支。8 条反例的构造与命中点如下:
| # | 反例构造 | 命中哪条判据 |
|---|---|---|
| 1 | cardCount: 11 | 等式两侧不等 |
| 2 | dealt: dealt + 1 | dealt !== enemyHp₀ − enemyHp |
| 3 | energy: energyMax + 1 | 超上界 |
| 4 | block: -1 | 下界为负 |
| 5 | enemyHp: enemyHp₀ + 1 | 超 enemyHp₀ |
| 6 | won: true, enemyHp: 10 | 蕴含后件假 |
| 7 | over: false, turn: 21 | 回合越界 |
| 8 | hand: ["heal"] | 池外 id |
反例的构造有一个反直觉之处:它们全都建立在真实终态 base 之上,只改一个字段。这样做的理由是隔离——如果反例同时改了多个字段,判据判 false 时你分不清是哪一处触发的。一次只改一个,才能保证「这条判据真的盯着这个字段」。脚本里那条 alwaysTrue 断言把这件事说透:一个恒返回 true 的检查器能通过全部正例,却在 8 条反例上全部漏判——它存在的唯一作用,就是提醒你「正例全过」不等于「检查有效」。
清单不能无限长。每加一条不变量,就多一份维护成本,也多一次「来源脱节」的机会。经验阈值是:清单条数应显著少于规则的可枚举错类数——本规则 4 种卡、2 条转移分支,8 条清单已经覆盖到目标层与状态封闭,再加就得先问「这条错误真的会发生吗」。一条永不失败的判据(比如前件恒假)留在清单里,只会稀释信号:它每次都亮绿灯,却从没拦下过任何东西。清单的价值不在条数多,而在每一条都咬得住一个具体的错。
与相邻章节的分工
| 章节 | 管什么 | 与清单的关系 |
|---|---|---|
| 3.2 | 误差表(差多少) | 同一判据的连续粒度,排障用 |
| 4.2 | invariantsHold(四条合成布尔) | 清单的子集,内核对外的自陈 |
| 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 死锁与可解性检测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。