引擎内核 的帧循环不会「卡住」——它每帧都跑;逻辑内核会。死锁是「未结束却无事可做」,不可解是「怎么打都赢不了」,两者都不是崩溃,因此不会被异常捕获,只能靠搜索证明。战斗手感实现 讲输入怎么变成动作,本章讲动作还能不能继续。4.2 的三类不变量只管单个状态合法不合法,本节管整张状态图通不通。
4.3 无死锁与可解性
三个概念要先分清,它们的失败方式完全不同:
| 概念 | 定义 | 在本规则下是否可达 | 谁来兜 |
|---|---|---|---|
| 死锁 | 未结束,且合法动作集为空 | 不可达:e 恒合法 | 搜索里计数,实测为 0 |
| 停滞 | 未结束、有合法动作,但每一个都不改变状态 | 不可达:e 必然推进 turn | 搜索里计数,实测为 0 |
| 不可解 | 不存在任何取胜序列 | 可达(换一组 params 即可) | 只能穷举前沿才能证明 |
前两个「不可达」不是靠搜索碰运气得出的,而是论证:e 的前置条件是恒真,副作用里 turn += 1 必然发生,所以「无合法动作」与「所有合法动作都是空操作」在本规则集下都构造不出来。搜索里的计数是这条论证的交叉验证,不是它的替代。
真正需要搜索的是第三个。本节的做法是广度优先穷举动作序列的前缀树:节点是命令序列,节点状态由 runSpec 前缀重放得到——状态机是纯函数,所以回放前 k 条命令给出的快照与「逐命令推进 k 次」逐字相同;子节点是当前状态下的合法动作。逐层展开,因此找到的第一条取胜序列必然最短。三重上限把爆炸按住:maxSteps 限深度、maxNodes 限去重后状态数、maxBranch 限单步分支。去重键用 digest,依据是 4.1 的状态充分性——digest 相同即状态相同,不会错杀。
复杂度上界很好写。分支因子 b ≤ handSize + 1 = 6(五张手牌 + 结束回合),深度上限 D,则不去重的节点数上界是等比级数 Σ(b^d, d=0..D) = (b^(D+1) - 1) / (b - 1)。取 D = 8、b = 6 得 (6^9 - 1) / 5 = 2015539。
实到状态数 1517,比上界少了三个数量级——差额全部来自 digest 去重:非法动作、欠费动作、以及不同顺序到达同一状态的三类重复都被折掉了。这也是为什么 maxNodes 必须按「状态数」计而不是按「序列数」计:按序列数计,同样的预算只能搜到浅得多的深度。
下面是 P 层新增的搜索器 core/src/ashdeck/solvability.ts。它最要紧的设计不是 BFS,而是把结论的可靠性分成两级写进返回值里:
// file: core/src/ashdeck/solvability.ts
// tier: P
// verify: node --test 'core/test/gd_mech_04_solvability.test.ts'
//
// 4.3 死锁与可解性:小规模穷举搜索。搜索树就是动作序列的**前缀树**。
// 两条前提都由 4.1 的测试背书:
// - 前缀重放可用:runSpec 是纯函数,故回放前 k 条命令给出的快照与
// 「逐命令推进 k 次」逐字相同;
// - digest 去重可用:digest 相同即状态相同(状态充分性),不会错杀。
//
// 本模块最要紧的一条是**结论的两级可靠性**:
// solvable === true —— 有一条取胜序列作为**证据**,与任何上限无关,恒可靠;
// solvable === false —— 只有当 exhausted === true(前沿在 maxSteps 内被穷尽)
// 时才是**证明**;truncated === true 时只能说「没搜到」。
//
// 本模块不读时钟、不读随机源(耗时由调用方测量),故 core/src/ashdeck/ 全目录
// 都满足「状态机确定性不依赖时间」——4.1 的源码扫描会断言这一点。
import type { Snapshot, Spec } from "./types.ts";
import { runSpec } from "./combat.ts";
import { digestSnap } from "./digest.ts";
export type Limits = {
maxSteps: number; // 动作序列长度上限
maxNodes: number; // 去重后状态数上限
maxBranch: number; // 单步候选动作上限(本规则下 = handSize + 1)
};
export const DEFAULT_LIMITS: Limits = { maxSteps: 12, maxNodes: 200000, maxBranch: 6 };
export type SearchReport = {
solvable: boolean;
solution: string[];
nodes: number; // 去重后的可达状态数(含根)
expanded: number; // 实际展开过的状态数
deadlocks: number; // 未结束且无合法动作
stalls: number; // 未结束、有合法动作,但每个都不改变状态
maxBranchSeen: number;
exhausted: boolean; // 前沿被穷尽 → solvable=false 是证明
truncated: boolean; // 撞上某个上限 → 结论不完整
};
const UNPLAYABLE = 999999;
/** 卡片费用;未知 id 视为不可负担。整数,无浮点。 */
export function cardCost(spec: Spec, id: string): number {
for (const c of spec.cards) if (c.id === id) return c.cost;
return UNPLAYABLE;
}
/**
* 合法动作集:可负担的出牌 p<i>,以及(默认)结束回合 e。
* allowEndTurn=false 是给检测器自证的**动作集变体**:本规则下 e 恒合法,
* 死锁不可达,只有把 e 摘掉才能构造出一个真会响的负例。
*/
export function legalActions(spec: Spec, s: Snapshot, allowEndTurn = true): string[] {
if (s.over) return [];
const out: string[] = [];
for (let i = 0; i < s.hand.length; i += 1) {
if (cardCost(spec, s.hand[i]) <= s.energy) out.push("p" + i);
}
if (allowEndTurn) out.push("e");
return out;
}
/** 死锁:未结束且没有任何合法动作。 */
export function isDeadlock(spec: Spec, s: Snapshot, allowEndTurn = true): boolean {
return !s.over && legalActions(spec, s, allowEndTurn).length === 0;
}
/** 停滞:未结束、有合法动作,但每一个都不改变状态。 */
export function isStall(spec: Spec, seq: string[], s?: Snapshot): boolean {
const snap = s ?? runSpec(spec, seq);
if (snap.over) return false;
const acts = legalActions(spec, snap);
if (acts.length === 0) return false;
const base = digestSnap(snap);
return acts.every((a) => digestSnap(runSpec(spec, seq.concat([a]))) === base);
}
/**
* 广度优先穷举:逐层展开,故找到的第一条取胜序列**最短**。
* 去重键是 digest——合法动作非法化(p99 / 欠费)不改变状态,因而天然收敛。
*/
export function searchSolvable(spec: Spec, limits: Limits = DEFAULT_LIMITS): SearchReport {
const root: string[] = [];
const seen = new Set<string>([digestSnap(runSpec(spec, root))]);
let frontier: string[][] = [root];
let nodes = 1;
let expanded = 0;
let deadlocks = 0;
let stalls = 0;
let maxBranchSeen = 0;
let solution: string[] | null = null;
let exhausted = false;
let truncated = false;
for (let d = 1; d <= limits.maxSteps; d += 1) {
const next: string[][] = [];
for (const seq of frontier) {
const s = runSpec(spec, seq);
expanded += 1;
let acts = legalActions(spec, s);
if (acts.length > maxBranchSeen) maxBranchSeen = acts.length;
if (!s.over && acts.length === 0) deadlocks += 1;
if (isStall(spec, seq, s)) stalls += 1;
if (acts.length > limits.maxBranch) {
truncated = true; // 分支被截 → 完备性受损,如实标注
acts = acts.slice(0, limits.maxBranch);
}
for (const a of acts) {
const child = seq.concat([a]);
const snap = runSpec(spec, child);
if (snap.won) {
solution = child;
break;
}
const g = digestSnap(snap);
if (seen.has(g)) continue;
if (nodes >= limits.maxNodes) {
truncated = true;
break;
}
seen.add(g);
nodes += 1;
next.push(child);
}
if (solution !== null || truncated) break;
}
if (solution !== null || truncated) break;
if (next.length === 0) {
exhausted = true; // 前沿空 = 可达状态空间被穷尽
break;
}
frontier = next;
}
if (solution === null && !exhausted) truncated = true;
return {
solvable: solution !== null,
solution: solution ?? [],
nodes,
expanded,
deadlocks,
stalls,
maxBranchSeen,
exhausted,
truncated,
};
}
solvable === true 与 solvable === false 的强度根本不同。前者由一条取胜序列作证,无论上限多紧都成立——证据不受搜索预算影响。后者只有在 exhausted === true(前沿被穷尽)时才是证明;一旦 truncated === true,false 只意味着「没搜到」。把这两个布尔分开返回,而不是合成一个 solvable,是本模块唯一不能省的设计。下面是测试里三条关键断言的骨架:
// snippet: core/test/gd_mech_04_solvability.test.ts 的三条关键断言
assert.equal(r.solvable, true);
assert.equal(r.solution.length, 8); // 可解:解长 8,重放后 won === true
assert.deepEqual(r.solution, ["p1", "p1", "e", "p3", "p3", "e", "p3", "p3"]);
assert.equal(r.solvable, false);
assert.equal(r.exhausted, true); // 不可解:前沿穷尽,这是证明
assert.equal(r.truncated, false);
const short = searchSolvable(unsolvable(), { ...DEFAULT_LIMITS, maxSteps: 8 });
assert.equal(short.exhausted, false);
assert.equal(short.truncated, true); // 8 步上限下只能说「没搜到」,不能说「不可解」
本机实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_04_*.test.ts',本节 8 条的真实输出(前两行是测试内部的报告行):
[4.3] 基线可解 解长=8 状态=1517 展开=912 耗时=11ms
[4.3] 不可解证明 状态=420 展开=420 耗时=5ms
✔ 动作集:p<i> 按费用过滤,e 恒合法;分支上界 = handSize + 1 (0.6615ms)
✔ 可解:基线 spec 存在取胜序列,重放后确实取胜 (12.072333ms)
✔ 最短性:广度优先逐层展开,7 步以内无解 (8.038042ms)
✔ 无死锁:基线搜索中死锁与停滞均为 0 (10.142333ms)
✔ 死锁检测器会响:摘掉 e 之后,空手牌状态就是一个真死锁 (0.187083ms)
✔ 不可解:999 血 + 2 回合上限,搜索穷尽前沿后判不可解 (5.290625ms)
✔ 上限诚实:同一不可解 spec 在 8 步上限下结论不完整 (3.804584ms)
✔ 去重有效:实到状态数远小于无去重的理论上界 (9.424ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 206.837833
三组数字值得逐条读。1517 个状态、11 毫秒换来一条 8 步取胜序列:p1, p1, e, p3, p3, e, p3, p3,重放后 won === true、enemyHp === 0、turn === 3。深度 7 的上限下搜索报告 solvable === false 且 truncated === true——这正是「最短性」那一条要抓的:如果没有这个布尔,false 会被误读成「7 步不可解」。
420 个状态、5 毫秒换来一个不可解证明。夹具把 enemyHp 抬到 999、turnLimit 压到 2:上限内每回合理论最大伤害是 jab + jab + heavy + strike = 3 + 3 + 14 + 6 = 26,两回合 52,远小于 999。所以这个夹具的不可解是参数层就能算出来的,搜索只是把它确认一遍——而恰恰因为状态空间小,前沿能在 11 步内被穷尽,exhausted === true 才站得住。把同一个夹具塞进 8 步上限,exhausted 变成 false、truncated 变成 true,结论立刻降级为「没搜到」。这两个数字放在一起,就是「上限诚实」那一条断言的全部意义。
死锁检测器本身也需要自证会响。本规则下 e 恒合法,所以死锁不可达——为了让检测器不是一段死代码,测试用了一个动作集变体:legalActions(spec, snapshot, false) 把 e 摘掉,此时空手牌状态就是一个真死锁,isDeadlock 返回 true;换回默认动作集,同一个状态立刻返回 false。这不是给规则开后门,而是给检测器配一个能判 true 的负例——4.2 已经说过,没有反面的断言等于没说。
那么什么时候该放弃穷举、改用采样?判据是三条,任一条成立就换:
| 判据 | 阈值 | 换成什么 |
|---|---|---|
| 理论上界超预算 | b^D > maxNodes | 限深随机 rollout,统计可解率(千分比) |
| 去重率过低 | 实到状态数 / 理论上界 > 1/10 | 说明状态表示太粗,先改 digest 再搜 |
| 撞上限仍无解 | truncated === true | 只能报告「限深内未找到」,不报「不可解」 |
本节的基线 spec 三条都不触发:实测去重率是 1517 / 2015539,约千分之一,远低于 1/10 的告警线;不可解夹具在 11 步内就穷尽了前沿,exhausted === true 站得住。真正需要换采样的场合,是牌池变大、手牌上限抬高或回合上限放宽之后——那时 b 与 D 同时上涨,b^D 会先失控。
| 检查项 | 合格标准 |
|---|---|
| 结论分级 | solvable / exhausted / truncated 三个布尔分开返回 |
| 上限三重 | 深度、状态数、分支各有一条硬上限,触发即标注 |
| 有可解样例 | 基线 spec 找到最短取胜序列,并重放验证 |
| 有不可解样例 | 夹具的不可解可独立于搜索论证,搜索只是确认 |
| 有复杂度说明 | 给出上界公式、实测状态数与「何时改采样」的判据 |
到这里,P 层的四件事就齐了:状态机是什么、哪些状态不允许存在、整张图通不通、以及为什么这些结论可复现。它们全部跑在命令行里,没有一行渲染代码。下一章把这份逻辑内核内联进一个单文件 HTML,让它能在浏览器里被真的玩起来——同源,但换一个宿主。
阅读导航:上一节:4.2 不变量断言:守恒、不可达、单调 · 下一节:5.1 规格内联进单文件 HTML 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。