4.3 无死锁与可解性

死锁是「未结束却无事可做」,不可解是「怎么打都赢不了」,两者都不是崩溃,因而不会被异常捕获,只能靠搜索证明。本节给《灰烬牌库》写一个带三重上限的广度优先穷举器:基线 spec 8 步可解、不可解夹具穷尽前沿后判不可解,并区分「有取胜序列作证据」与「前沿穷尽才是证明」这两种可靠性等级。

引擎内核 的帧循环不会「卡住」——它每帧都跑;逻辑内核会。死锁是「未结束却无事可做」,不可解是「怎么打都赢不了」,两者都不是崩溃,因此不会被异常捕获,只能靠搜索证明。战斗手感实现 讲输入怎么变成动作,本章讲动作还能不能继续。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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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