引擎内核
那一栏讲的是渲染、资源管理与帧循环——引擎怎么把状态画出来;本套的 core/ 是逻辑内核:只描述规则与状态,一行渲染代码都没有,因此它才能被穷举、被断言、被跨语言对照。战斗手感实现
讲的是输入缓冲与打击感的实现链路,本章把同一场战斗当机制看:状态怎么变、由谁触发、变了多少。3.1 的 S 层已经把规格跑成轨迹,本节换到 P 层问一个更硬的问题:这台机器凭什么是可复现的。
4.1 规格驱动的状态机
一份玩法规格要能被「跑」,先得回答一个问题:它是什么机器。《灰烬牌库》的答案是确定性状态机,三要素缺一不可:
| 要素 | 在规格里的位置 | 在《灰烬牌库》里是什么 |
|---|---|---|
| 状态集合 S | params + 运行时 | turn / energy / playerHp / enemyHp / block 等标量 + 手牌 / 牌库 / 弃牌堆三区 |
| 转移函数 δ | cards + goals | p<i> 出第 i 张手牌、e 结束回合 |
| 输入序列 x | strategy | 25 个命令的数组,玩家记录、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 不变量断言:守恒、不可达、单调 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。