4.2 不变量断言:守恒、不可达、单调

不变量就是「永远不该出现的状态」。本节把《灰烬牌库》的不变量分成三类:守恒(牌数恒 12、伤害恒等于已扣敌方血量)、不可达(非法动作与欠费动作执行后状态逐字不变)、单调(enemyHp 与 playerHp 逐步不增)。关键手法是逐步查而非只看终态,并且每一类都配一个负例断言为 false。

引擎内核 关心的是「一帧里状态怎么被画出来」;本节关心的是「哪些状态根本不允许存在」。不变量就是状态机的不动点集合:跑得再久也不能跑出去。战斗手感实现 讲的是输入到反馈的实现链路,本章讲的是规则层的不变量——它是机制正确性的最小验收面。3.2 的 S 层已经用守恒检验过资源,本节把它搬进 P 层,并补上另外两类。

4.2 不变量断言:守恒、不可达、单调

不变量是一句全称命题:在所有可达状态上成立。「在所有可达状态上」这几个字很容易被悄悄缩水成「在终态上」——而终态只是一个点。本节的手法是逐步查:因为状态机是纯函数,runSpec(spec, seq.slice(0, k)) 得到的就是回放第 k 步后的状态,于是可以把 25 个命令的 strategy 展开成 26 个状态的轨迹,在每一步上查不变量。

三类不变量各有各的失败方式:

类命题破了会怎样判据
守恒牌数恒为 deckSize、dealt 恒等于已扣敌方血量牌凭空多出或消失、伤害统计失真cardCount === 12 且 dealt === enemyHp₀ - enemyHp
不可达非法动作与欠费动作不产生任何状态变化越界访问、负能量、凭空出牌执行前后 digest 逐字相同
单调enemyHp 与 playerHp 逐步不增、turn 与 played 逐步不减血量回弹、回合倒退、进度回滚相邻两步的差不为正(或不为负)

守恒这一类里,damageConserved 那个布尔字段只是终态的自陈,真正可查的是等式本身。牌数守恒尤其值得逐步查:手牌、牌库、弃牌堆三区在出牌与结束回合时此消彼长,只有每一步都算一遍才知道有没有哪一次转移漏掉了牌。规格里的 invariants 数组声明了四条,逐条都能指到实现:

声明落地判据属于哪一类
cardCount==deckSize三区之和 === 12守恒(等式型)
damageConserveddealt === enemyHp₀ - enemyHp守恒(等式型)
energyInRange0 ≤ energy ≤ energyMax守恒(区间型)
blockNonNegativeblock ≥ 0守恒(下界型)

四条声明在实现里展开成五个比较——energyInRange 一条占两个。这说明规格里的不变量名是语义标签,不是可以直接求值的表达式;把它当表达式执行,就得在 config/ 里塞进一门小语言。

三类不变量还有一层区分值得记住:结构性的只要规则不改就恒成立(牌数守恒、block ≥ 0),数值性的会随参数与卡池变化(damageConserved 在加入治疗牌后立刻失效,energy ≤ energyMax 在 energyMax 改成 0 时仍然成立但变得没有信息量)。改规格时先看动到的是哪一类,才知道要不要连断言一起改。对应的实现只有几行:

// snippet: core/src/ashdeck/combat.ts 的 invariantsHold(四条声明的落地)
export function invariantsHold(spec: Spec, s: Snapshot): boolean {
  return (
    s.cardCount === spec.cards.reduce((a, c) => a + c.count, 0) &&
    s.damageConserved === true &&
    s.energy >= 0 &&
    s.energy <= spec.params.energyMax &&
    s.block >= 0
  );
}

不可达这一类最反直觉的地方在于「动作失败」的两种写法。把非法动作写成 throw,状态机会崩;写成「状态回滚」,就引入了一次先改后撤的窗口;runSpec 选的是第三种:在动手之前就 continue。于是非法动作与欠费动作执行后,状态不是「被复原」,而是从未被碰过。测试里对这一点用的是最强判据——不是比较某个字段,而是比较整个快照的 digest。

把这条判据套到规格自带的那条 strategy 上,会得到一个不太好看但很有用的普查结果:25 个命令里有 13 个是空转步。前 4 个是欠费——第 4、9 步手上只有 guard / strike(费用 1)而能量已经见底,第 13、14 步手上第一张是 heavy(费用 2)而能量只剩 1;后 9 个是第 16 步已经取胜、over 短路了循环。也就是说,这条策略只有 12 步真正做了事。

位置命令空转原因该步的能量 / 手牌首张
第 4 步p0欠费energy=0,hand[0]=guard(费用 1)
第 9 步p0欠费energy=0,hand[0]=strike(费用 1)
第 13、14 步p0欠费energy=1,hand[0]=heavy(费用 2)
第 17–25 步p0 / e终局后第 16 步已 won,over 短路

这张表有两层用处。一是它证明「不可达」不是构造出来的边角料:规格自带的策略里就有一堆,任何依赖命令计数的统计(比如「平均每步伤害」)都必须先把空转步剔掉,否则分母是错的。二是它顺带暴露了 strategy 的一个性质:它不是「最优解」,只是一条能赢的演示序列——末尾那 9 个命令纯粹是写规格时凑长度留下的。

单调这一类要给「回弹」留出反面。enemyHp 单调不增是伤害封顶 Math.min(c.damage, enemyHp) 的直接后果;playerHp 单调不增是因为它只在 e 里减少、从不增加(本项目没有治疗牌——这是规格选择,不是规则漏洞,加了治疗牌这条不变量就得改写)。turn 与 played 则是单调不减的计数器。另外,over 置位之后 δ 不再转移,轨迹会被冻结成一条常值尾巴(第 16 步到第 25 步全同)——常值当然是单调的,所以单调性在这段尾巴上自动成立。

逐步查不是免费的:轨迹有 n + 1 个状态,每个状态都要从零重放前 k 条命令,最坏代价是 O(n²)。本节 n = 25,26 次重放加起来不到 1 毫秒;命令数上千时就得改成增量推进,或者只在转移真的发生的位置查。

下面这段是 P 层的验收脚本,10 条断言覆盖三类不变量,其中 3 条是负例:

// file: core/test/gd_mech_04_invariants.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_04_invariants.test.ts'
//
// 4.2 三类不变量:守恒 / 不可达 / 单调。每一类都配一个**负例断言为 false**,
// 否则「不变量成立」只是一句没有反面的空话。
//
// 手法:状态机是纯函数,故 runSpec(spec, seq.slice(0, k)) 得到的就是回放第 k 步
// 的状态——于是可以在**每一步**上查不变量,而不是只看终态。

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 { digestSnap } from "../src/ashdeck/digest.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 DECK = SPEC.cards.reduce((a, c) => a + c.count, 0);

/** 逐步轨迹:第 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;
}

/** 单调不增判据。 */
function nonIncreasing(xs: number[]): boolean {
  for (let i = 1; i < xs.length; i += 1) if (xs[i] > xs[i - 1]) return false;
  return true;
}

/** 单调不减判据。 */
function nonDecreasing(xs: number[]): boolean {
  for (let i = 1; i < xs.length; i += 1) if (xs[i] < xs[i - 1]) return false;
  return true;
}

const TRACE = trace(SPEC, SPEC.strategy);
const LAST = TRACE[TRACE.length - 1];

test("守恒①:牌数恒为 12、伤害恒等于已扣敌方血量,逐步成立", () => {
  assert.equal(DECK, 12);
  assert.equal(TRACE.length, 26);
  for (const s of TRACE) {
    assert.equal(s.cardCount, DECK);
    assert.equal(s.damageConserved, true);
    assert.equal(s.dealt, SPEC.params.enemyHp - s.enemyHp);
  }
  assert.equal(LAST.won, true);
});

test("守恒②:逐步满足 invariantsHold(能量与格挡不越界)", () => {
  for (const s of TRACE) assert.equal(invariantsHold(SPEC, s), true);
});

test("负例:守恒被破坏时 invariantsHold 必须为 false", () => {
  assert.equal(invariantsHold(SPEC, { ...LAST, cardCount: DECK - 1 }), false);
  assert.equal(invariantsHold(SPEC, { ...LAST, damageConserved: false }), false);
  assert.equal(invariantsHold(SPEC, { ...LAST, energy: SPEC.params.energyMax + 1 }), false);
  assert.equal(invariantsHold(SPEC, { ...LAST, energy: -1 }), false);
  assert.equal(invariantsHold(SPEC, { ...LAST, block: -1 }), false);
});

test("不可达①:非法动作 p99 执行后状态与不执行逐字相同", () => {
  const noop = runSpec(SPEC, ["p99"]);
  const base = runSpec(SPEC, []);
  assert.equal(digestSnap(noop), digestSnap(base));
  assert.equal(noop.played, 0);
  assert.equal(noop.enemyHp, SPEC.params.enemyHp);
});

test("不可达②:欠费动作被 continue 跳过,状态完全不变", () => {
  const before = runSpec(SPEC, ["p1", "p1"]); // 能量 3 -> 1 -> 0
  assert.equal(before.energy, 0);
  assert.equal(before.hand[1], "guard"); // guard 费用 1 > 能量 0
  const after = runSpec(SPEC, ["p1", "p1", "p1"]);
  assert.equal(digestSnap(after), digestSnap(before));
  assert.equal(after.played, before.played);
});

test("不可达③:规格自带的策略里有 13 个空转步(4 个欠费 + 9 个终局后)", () => {
  const noop: number[] = [];
  for (let k = 1; k <= SPEC.strategy.length; k += 1) {
    if (digestSnap(TRACE[k]) === digestSnap(TRACE[k - 1])) noop.push(k);
  }
  assert.equal(SPEC.strategy.length, 25);
  assert.equal(noop.length, 13);
  assert.deepEqual(noop, [4, 9, 13, 14, 17, 18, 19, 20, 21, 22, 23, 24, 25]);
  assert.equal(TRACE[4].energy, 0);
  assert.equal(TRACE[4].hand[0], "guard"); // 费用 1 > 能量 0
  assert.equal(TRACE[9].hand[0], "strike"); // 费用 1 > 能量 0
  assert.equal(TRACE[13].energy, 1);
  assert.equal(TRACE[13].hand[0], "heavy"); // 费用 2 > 能量 1
  assert.equal(TRACE[16].won, true); // 第 16 步取胜,其后 9 步被 over 短路
  for (let k = 17; k <= 25; k += 1) assert.equal(TRACE[k].over, true);
});

test("负例:可负担的动作必须真的改变状态(否则「不可达」判据自身失效)", () => {
  const before = runSpec(SPEC, ["p1", "p1"]);
  const after = runSpec(SPEC, ["p1", "p1", "p0"]); // jab 费用 0,永远可负担
  assert.notEqual(digestSnap(after), digestSnap(before));
  assert.equal(after.played, before.played + 1);
});

test("单调①:enemyHp 与 playerHp 逐步不增,turn 与 played 逐步不减", () => {
  assert.equal(nonIncreasing(TRACE.map((s) => s.enemyHp)), true);
  assert.equal(nonIncreasing(TRACE.map((s) => s.playerHp)), true);
  assert.equal(nonDecreasing(TRACE.map((s) => s.turn)), true);
  assert.equal(nonDecreasing(TRACE.map((s) => s.played)), true);
  assert.equal(TRACE[0].enemyHp, 50);
  assert.equal(LAST.enemyHp, 0);
});

test("负例:单调判据必须能判 false", () => {
  assert.equal(nonIncreasing([50, 45, 51]), false);
  assert.equal(nonIncreasing([50, 50, 49]), true);
  assert.equal(nonDecreasing([1, 2, 1]), false);
  const broken = TRACE.map((s) => s.enemyHp);
  broken[1] = broken[0] + 1; // 人为造一次回弹
  assert.equal(nonIncreasing(broken), false);
});

test("三类不变量合起来:基线终态同时满足守恒、无越界、单调三面", () => {
  assert.equal(invariantsHold(SPEC, LAST), true);
  assert.equal(nonIncreasing(TRACE.map((s) => s.enemyHp)), true);
  assert.equal(LAST.over, true);
  assert.equal(LAST.turn, 4);
  assert.equal(LAST.played, 9);
});

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

✔ 守恒①:牌数恒为 12、伤害恒等于已扣敌方血量,逐步成立 (0.514542ms)
✔ 守恒②:逐步满足 invariantsHold(能量与格挡不越界) (0.096125ms)
✔ 负例:守恒被破坏时 invariantsHold 必须为 false (0.073583ms)
✔ 不可达①:非法动作 p99 执行后状态与不执行逐字相同 (0.102458ms)
✔ 不可达②:欠费动作被 continue 跳过,状态完全不变 (0.076291ms)
✔ 不可达③:规格自带的策略里有 13 个空转步(4 个欠费 + 9 个终局后) (0.430958ms)
✔ 负例:可负担的动作必须真的改变状态(否则「不可达」判据自身失效) (0.080417ms)
✔ 单调①:enemyHp 与 playerHp 逐步不增,turn 与 played 逐步不减 (0.094333ms)
✔ 负例:单调判据必须能判 false (0.079541ms)
✔ 三类不变量合起来:基线终态同时满足守恒、无越界、单调三面 (0.110708ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 200.77125

三条负例的分工不一样。守恒的负例最直接:把 cardCount 改成 11、把 damageConserved 置 false、把 energy 顶到上限之上或压到负数、把 block 压成 -1,invariantsHold 必须全部返回 false——只要有一条漏判,那个字段就是摆设。单调的负例检验判据本身:nonIncreasing([50, 45, 51]) 必须为 false,否则「单调」这个词什么也没说。不可达的负例最容易被忽略:它要求一个可负担的动作必须真的改变状态。如果没有这条,一个「任何动作都不改变状态」的坏实现会把前两条不可达断言全部通过。

还有一个数字值得留意:轨迹长 26,而 strategy 只有 25 个命令——第 0 项是空序列,也就是开局状态。开局状态同样在查范围内:cardCount === 12、dealt === 0、enemyHp === 50。把「还没动」当成不需要检查的状态,是逐步检查最常见的漏。反过来,把「已经结束」当成不需要检查的状态同样错:第 16 步之后那 9 个常值状态也在轨迹里,它们既是 over 短路生效的证据,也是「常值序列自动满足单调」的例子。

检查项合格标准
逐步而非终态轨迹含开局状态,长度 = 命令数 + 1
三类齐全守恒 / 不可达 / 单调各至少一条断言
负例齐全每一类都有一个断言为 false 的反面
不可达有对照组可负担动作必须改变状态,否则前一条无意义
空转步要剔除统计类指标的分母不能把欠费与终局后的命令算进去

守恒说的是「牌和伤害不能凭空增减」,单调说的是「进度不能倒退」,不可达说的是「非法动作什么也不做」。三类合起来,仍然没有回答最要紧的那个问题:这局游戏打得赢吗,会不会卡死。下一节用穷举搜索回答它。

阅读导航:上一节:4.1 规格驱动的状态机 · 下一节:4.3 无死锁与可解性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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