4.1 规格驱动的状态机

状态机不是「带 if-else 的循环」,而是三件东西:一个状态集合、一个转移函数、一条输入序列。《灰烬牌库》的 runSpec 把三者写成不到 60 行的纯函数,本节用 9 条断言把它钉死——同 seed 同输入 200 次回放 digest 全同、换输入 digest 必变、换 seed digest 必变,最后扫一遍内核源码证明它既不读时钟也不读随机源。

引擎内核 那一栏讲的是渲染、资源管理与帧循环——引擎怎么把状态画出来;本套的 core/ 是逻辑内核:只描述规则与状态,一行渲染代码都没有,因此它才能被穷举、被断言、被跨语言对照。战斗手感实现 讲的是输入缓冲与打击感的实现链路,本章把同一场战斗当机制看:状态怎么变、由谁触发、变了多少。3.1 的 S 层已经把规格跑成轨迹,本节换到 P 层问一个更硬的问题:这台机器凭什么是可复现的。

4.1 规格驱动的状态机

一份玩法规格要能被「跑」,先得回答一个问题:它是什么机器。《灰烬牌库》的答案是确定性状态机,三要素缺一不可:

要素在规格里的位置在《灰烬牌库》里是什么
状态集合 Sparams + 运行时turn / energy / playerHp / enemyHp / block 等标量 + 手牌 / 牌库 / 弃牌堆三区
转移函数 δcards + goalsp<i> 出第 i 张手牌、e 结束回合
输入序列 xstrategy25 个命令的数组,玩家记录、AI 策略与测试夹具共用同一种表示

状态集合分成两类。标量是能直接比大小的量:turn(回合数)、energy(当回合剩余能量)、playerHp、enemyHp、block(当回合格挡)、dealt(累计已造成伤害)、played(累计出牌数)。三区牌是 hand / draw / discard——三区之和恒等于 deckSize,这条守恒正是 4.2 的第一类不变量。再加上终局标志 over / won 与派生布尔 damageConserved,凑成快照的 12 个字段。

这 12 个字段能逐个对上来源,「状态集合有穷」才不是一句空话:

字段类别由什么决定
turn标量每次 e 加一,初始 1
energy标量每次 e 重置为 energyMax,出牌时递减
playerHp标量初始 params.playerHp,只在 e 里减少
enemyHp标量初始 params.enemyHp,只在出牌时减少
block标量出牌累加,e 里先抵扣敌人攻击再清零
dealt / played标量只增不减的累计计数器
hand / draw / discard三区牌洗牌决定初始划分,出牌与 e 在三区间搬运
over / won终局标志血量归零或回合超限时置位,置位后 δ 不再转移
cardCount / damageConserved派生量由三区之和与 dealt 算出,不独立存储

最后一行值得停一下:cardCount 与 damageConserved 是派生量,不是新状态。它们出现在快照里,只是为了给 4.2 的断言一个现成的抓手;真把它们当成可写字段,恰恰是守恒被破坏的常见起点。

转移函数 δ 只有两条分支,没有第三条。下面是从 core/src/ashdeck/combat.ts 摘出的主干(省略了规格读取与返回值组装):

// snippet: core/src/ashdeck/combat.ts 的转移函数主干(省略规格读取与返回值)
if (cmd.startsWith("p")) {
  const idx = Number(cmd.slice(1));
  if (idx < 0 || idx >= hand.length) continue;   // 非法索引:状态不变
  const c = hand[idx];
  if (c.cost > energy) continue;                 // 欠费:状态不变
  hand.splice(idx, 1);
  discard.push(c);
  energy -= c.cost;
  block += c.block;
  const d = Math.min(c.damage, enemyHp);         // 伤害封顶,不溢出
  enemyHp -= d;
  dealt += d;
  played += 1;
  if (enemyHp <= 0) { over = true; won = true; }
} else if (cmd === "e") {
  const absorbed = Math.min(block, p.enemyAttack);
  block -= absorbed;
  playerHp = Math.max(0, playerHp - (p.enemyAttack - absorbed));
  if (playerHp <= 0) { over = true; won = false; break; }
  turn += 1;
  block = 0;
  energy = p.energyMax;
  while (hand.length < p.handSize && draw.length > 0) hand.push(draw.shift() as Card);
}

把 δ 拆成一张表,两个动作各自的前置条件与副作用就一目了然:

动作前置条件副作用
p<i>0 ≤ i < hand.length 且 cost(hand[i]) ≤ energy手牌 → 弃牌堆、energy -= cost、block += block、enemyHp -= min(damage, enemyHp)、dealt += d、played += 1
e恒真敌人攻击(先扣 block 再扣血)、turn += 1、block = 0、energy = energyMax、从 draw 补满 hand

两条前置条件不满足时,continue 让状态逐字不变——这不是「动作被拒绝」,而是「这个命令根本不是一次转移」。它正是 4.2 的「不可达」类不变量的来源。另外注意 Math.min(c.damage, enemyHp) 这个封顶:少了它,dealt 会超过敌方总血量,伤害守恒立刻破。

第三个要素是输入序列。规格里的 strategy 是 25 个命令的数组,形如 ["p0","p0","p0","p0","e", ...]。它同时是玩家的操作记录、AI 的策略与测试的夹具——三处消费共用一种表示,这是「一份规格四处消费」在 P 层的样子。要留意的是:strategy 不属于状态。同一份 params + 同一份 cards + 同一条 strategy,无论在哪台机器上跑、什么时候跑,终态都必须相同。

下面这段是 P 层的验收脚本,9 条断言分成四组:状态集合的完备性、转移函数的语义、四条确定性、以及一条为 4.3 铺路的状态充分性。

// file: core/test/gd_mech_04_state_machine.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_04_state_machine.test.ts'
//
// 4.1 规格驱动的状态机:三要素(状态集合 / 转移函数 / 输入序列)+ 四条确定性断言。
// 「确定性不依赖时间」不是靠跑两遍碰运气,而是**扫源码**:core/src/ashdeck/ 全目录
// 不得出现时钟与随机源读取。本文件只读规格与内核源码,不写任何文件。

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

import { runSpec } from "../src/ashdeck/combat.ts";
import { digestSnap } from "../src/ashdeck/digest.ts";
import type { Spec } from "../src/ashdeck/types.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, "..", "src", "ashdeck");
const SPEC: Spec = JSON.parse(
  readFileSync(join(HERE, "..", "..", "config", "play_spec.json"), "utf8"),
);

const STATE_KEYS = [
  "block", "cardCount", "damageConserved", "dealt", "enemyHp", "energy",
  "hand", "over", "played", "playerHp", "turn", "won",
];

test("状态集合:快照字段恰好是约定的 12 个状态量", () => {
  assert.deepEqual(Object.keys(runSpec(SPEC)).sort(), STATE_KEYS.slice().sort());
  assert.equal(SPEC.strategy.length, 25);
});

test("转移函数 p<i>:扣能量、结算伤害、手牌移入弃牌堆", () => {
  const s = runSpec(SPEC, ["p1"]); // hand[1] = heavy,费用 2、伤害 14
  assert.equal(s.energy, 1);
  assert.equal(s.enemyHp, 36);
  assert.equal(s.dealt, 14);
  assert.equal(s.played, 1);
  assert.deepEqual(s.hand, ["jab", "strike", "guard", "guard"]);
  assert.equal(s.cardCount, 12);
  assert.equal(s.turn, 1);
});

test("转移函数 e:敌人攻击、回合 +1、能量重置、手牌补满", () => {
  const s = runSpec(SPEC, ["e"]);
  assert.equal(s.turn, 2);
  assert.equal(s.playerHp, 54); // 60 - 6,无格挡
  assert.equal(s.energy, 3);
  assert.equal(s.block, 0);
  assert.deepEqual(s.hand, ["jab", "heavy", "strike", "guard", "guard"]);
});

test("确定性①:同 seed 同输入,200 次回放 digest 全同", () => {
  const first = digestSnap(runSpec(SPEC));
  for (let i = 0; i < 200; i += 1) assert.equal(digestSnap(runSpec(SPEC)), first);
});

test("确定性②:不同输入序列给出不同 digest(非法动作除外)", () => {
  const g = (seq: string[]) => digestSnap(runSpec(SPEC, seq));
  const all = [g([]), g(["p0"]), g(["p1"]), g(["e"]), g(["p99"])];
  assert.equal(new Set(all).size, 4); // p99 被 continue,与空序列同状态
  assert.equal(g([]), g(["p99"]));
});

test("确定性③:换 seed 换输出,确定性来自 seed 而非环境", () => {
  const a = digestSnap(runSpec({ ...SPEC, seed: 1 }));
  const b = digestSnap(runSpec({ ...SPEC, seed: 2 }));
  assert.notEqual(a, b);
});

/** 先剥注释再扫——rng.ts 的说明文字里就写着「绝不用 Math.random()」,不剥会假阳性。 */
function stripComments(src: string): string {
  return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
}

test("确定性④:core/src/ashdeck/ 全目录不读时钟、不读随机源", () => {
  const files = readdirSync(SRC).filter((f) => f.endsWith(".ts")).sort();
  assert.ok(files.length >= 5, `内核文件数 ${files.length}`);
  const forbidden = [
    /\bMath\.random\s*\(/,
    /\bnew\s+Date\b/,
    /\bDate\.now\b/,
    /\bperformance\.now\b/,
    /\bprocess\.hrtime\b/,
    /\bsetTimeout\b/,
    /\bsetInterval\b/,
    /from\s+["']node:crypto["']/,
  ];
  for (const f of files) {
    const code = stripComments(readFileSync(join(SRC, f), "utf8"));
    for (const re of forbidden) {
      assert.equal(re.test(code), false, `${f} 命中禁读源 ${re}`);
    }
  }
});

test("纯函数:runSpec 不修改 spec,也不修改传入的策略数组", () => {
  const specBefore = JSON.stringify(SPEC);
  const seq = ["p1", "e", "p0"];
  const seqBefore = JSON.stringify(seq);
  runSpec(SPEC, seq);
  assert.equal(JSON.stringify(SPEC), specBefore);
  assert.equal(JSON.stringify(seq), seqBefore);
});

test("状态充分性:digest 相同则后续同输入给出同结果(4.3 去重的前提)", () => {
  const a = ["p99"]; // 非法动作,状态与空序列逐字相同
  const b: string[] = [];
  assert.equal(digestSnap(runSpec(SPEC, a)), digestSnap(runSpec(SPEC, b)));
  for (const suffix of [["p1"], ["e"], ["p0", "e", "p1"]]) {
    assert.equal(
      digestSnap(runSpec(SPEC, a.concat(suffix))),
      digestSnap(runSpec(SPEC, b.concat(suffix))),
    );
  }
});

本机实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_04_*.test.ts',本节 9 条(全章 glob 共 27 条:本节 9 / 4.2 10 / 4.3 8)的真实输出:

✔ 状态集合:快照字段恰好是约定的 12 个状态量 (0.997917ms)
✔ 转移函数 p<i>:扣能量、结算伤害、手牌移入弃牌堆 (0.099958ms)
✔ 转移函数 e:敌人攻击、回合 +1、能量重置、手牌补满 (0.07575ms)
✔ 确定性①:同 seed 同输入,200 次回放 digest 全同 (0.963166ms)
✔ 确定性②:不同输入序列给出不同 digest(非法动作除外) (0.089167ms)
✔ 确定性③:换 seed 换输出,确定性来自 seed 而非环境 (0.076917ms)
✔ 确定性④:core/src/ashdeck/ 全目录不读时钟、不读随机源 (26.747917ms)
✔ 纯函数:runSpec 不修改 spec,也不修改传入的策略数组 (0.172375ms)
✔ 状态充分性:digest 相同则后续同输入给出同结果(4.3 去重的前提) (0.147833ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 200.561125

四条确定性里最容易被糊弄过去的是第四条。跑两遍 digest 相同只能说明「这次没出错」,说明不了「不会出错」;真正要证明的是内核里根本没有可以引入不确定性的入口。所以那条断言不是重跑,而是扫源码:把 core/src/ashdeck/ 下每个 .ts 文件读进来,先剥掉注释,再逐条匹配 Math.random(、new Date、Date.now、performance.now、process.hrtime、setTimeout、setInterval、node:crypto。剥注释这一步是必须的——rng.ts 的说明文字里就写着「绝不用 Math.random()」,不剥会立刻假阳性。这一条同时也是 4.3 那个搜索器允许计时的理由:计时器只出现在测试文件里,不在内核里。

确定性② 里藏着一个细节:p99 与空序列的 digest 完全相同,所以五个候选序列只给出 4 个不同 digest。这不是测试写得松,而是上一节那条 continue 的直接后果——非法命令不构成转移。

最后一条「状态充分性」是 4.3 的地基:如果两条不同命令序列走到 digest 相同的状态,那么给它们接上任何相同后缀,结果也必然相同。换句话说,digest 就是状态的充分表示,搜索器才可以拿 digest 当去重键,而不必担心「两个 digest 相同的状态其实不一样」。

检查项合格标准
状态集合有穷快照字段是固定集合,手牌 / 牌库 / 弃牌堆三区之和守恒
转移函数封闭δ 只有 p<i> 与 e 两条分支,前置不满足时不产生转移
确定性可证不只重跑比对,还要扫源码确认无时钟、无随机源入口
状态充分digest 相同 ⇒ 后续同输入同结果,否则 4.3 的去重不成立

三要素立住之后,下一个问题自然浮现:这 12 个字段里,哪些组合永远不该出现。下一节把它们分成守恒、不可达、单调三类,每一类都配一个负例。

阅读导航:上一节:3.3 仿真输出的可读化 · 下一节:4.2 不变量断言:守恒、不可达、单调 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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