10.3 死锁与可解性检测

「卡住」有四种形态:无合法动作的死锁、有动作却不改变状态的停滞、资源不可达目标的不可解、以及撞上上限的截断。本节把 4.3 的搜索器包成一张判据表,用四个夹具各命中一类,并钉死结论的可靠性边界——solvable=true 有证据恒可靠,false 只在 exhausted=true 时才是证明。

SLG 模块拆分 关心的是系统模块怎么切、依赖怎么连;本节关心的是状态图通不通——有没有走不出去的死角、有没有怎么打都赢不了的开局。这是状态空间语义的最后一问。4.3 已经交付了 core/src/ashdeck/solvability.ts:广度优先穷举 + digest 去重 + 三重上限,并把结论拆成 solvable / exhausted / truncated 三个布尔。本节不重复那套实现,而是在它之上做两件事:把「卡住」拆成一张判据表,并说清每条结论的可靠性边界。

10.3 死锁与可解性检测

「卡住」不是一个概念,是四种。它们的检测方式、可达性、结论强度都不同,混为一谈是这一节最容易犯的错:

判据定义检测方式本规则下可达结论强度
死锁未结束且合法动作集为空legalActions(s).length === 0摘掉 e 的动作集变体下可达状态级事实
停滞未结束、有合法动作,但每个都不改变状态比较每个动作后快照的 digest不可达(e 必然推进 turn)状态级事实
不可解不存在任何取胜序列穷尽前沿后仍无 won可达(换一组 params)仅 exhausted 时是证明
截断撞上 maxSteps / maxNodes / maxBranch搜索器如实置位极易触发只是「没搜到」

前两类是状态级判据,后两类是整图级判据。searchSolvable 在搜索时顺带计数 deadlocks 与 stalls,于是四种形态能在一次搜索里同时报出来。四条判据在 4.3 的模块里各有落点,本节脚本一一对应地复刻:

判据solvability.ts 里的落地本节脚本里的落地
合法动作集legalActions(spec, s, allowEndTurn)acts_of(spec, s, allow_e)
死锁isDeadlock(spec, s, allowEndTurn)not acts_of(...) 与 deadlocks 计数
停滞isStall(spec, seq, s)is_stall(spec, seq, s, allow_e)
可解 / 不可解 / 截断searchSolvable(spec, limits)search(spec, max_steps, ...)

函数名一一对应不是巧合——本节的 Python 脚本刻意与 solvability.ts 同口径,才能拿它复算同一批夹具、得到一致的数字(baseline 1517 个状态、8 步解,与 4.3 逐字相同)。跨语言对照的意义正在于此:同一套判据、两种宿主,结论必须对得上;对不上就说明有一侧实现漂移了。第 15 章会把这种对照做成常设的偏差检查。

下面这段 S 层脚本用与 solvability.ts 同口径的 Python BFS,把四个夹具各推一类:

#!/usr/bin/env python3
# file: sim/gd_mech_10_detection.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_10_detection.py
"""10.3 死锁与可解性检测(S 层)。

4.3 已交付 core/src/ashdeck/solvability.ts(BFS + digest 去重 + 三重上限)。本节在它
**之上**给一张判据表,把四种「卡住」分门别类检测出来,并说清可靠性边界:
  可解       找到取胜序列 → solvable=True,恒可靠(有证据)
  不可解证明 前沿被穷尽(exhausted=True)→ solvable=False 是证明
  截断       撞上某个上限(truncated=True)→ 只能说「限深内没搜到」
  死锁       未结束且无合法动作(摘掉 e 的动作集变体下才可达)

用与 solvability.ts 同口径的 Python BFS 逐 fixture 复算,输出 CSV 与判据表。
全整数,无浮点,无 random。
"""
from __future__ import annotations

import copy
import os
import sys

import ashlib

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def cost(spec, cid):
    return next((c["cost"] for c in spec["cards"] if c["id"] == cid), 999999)


def acts_of(spec, s, allow_e=True):
    if s["over"]:
        return []
    a = ["p" + str(i) for i, cid in enumerate(s["hand"]) if cost(spec, cid) <= s["energy"]]
    if allow_e:
        a.append("e")
    return a


def is_stall(spec, seq, s, allow_e=True):
    if s["over"]:
        return False
    a = acts_of(spec, s, allow_e)
    if not a:
        return False
    base = ashlib.digest(s)
    return all(ashlib.digest(ashlib.run_spec(spec, seq + [x])) == base for x in a)


def search(spec, max_steps=12, max_nodes=200000, max_branch=6, allow_e=True):
    """与 core/src/ashdeck/solvability.ts 的 searchSolvable 同口径。"""
    seen = {ashlib.digest(ashlib.run_spec(spec, []))}
    frontier = [[]]
    nodes, expanded, deadlocks, stalls, max_seen = 1, 0, 0, 0, 0
    solution, exhausted, truncated = None, False, False
    for _ in range(1, max_steps + 1):
        nxt = []
        for seq in frontier:
            s = ashlib.run_spec(spec, seq)
            expanded += 1
            a = acts_of(spec, s, allow_e)
            max_seen = max(max_seen, len(a))
            if not s["over"] and not a:
                deadlocks += 1
            if is_stall(spec, seq, s, allow_e):
                stalls += 1
            if len(a) > max_branch:
                truncated, a = True, a[:max_branch]
            for x in a:
                child = seq + [x]
                snap = ashlib.run_spec(spec, child)
                if snap["won"]:
                    solution = child
                    break
                g = ashlib.digest(snap)
                if g in seen:
                    continue
                if nodes >= max_nodes:
                    truncated = True
                    break
                seen.add(g)
                nodes += 1
                nxt.append(child)
            if solution is not None or truncated:
                break
        if solution is not None or truncated:
            break
        if not nxt:
            exhausted = True
            break
        frontier = nxt
    if solution is None and not exhausted:
        truncated = True
    return {"solvable": solution is not None, "solution": solution or [], "nodes": nodes,
            "expanded": expanded, "deadlocks": deadlocks, "stalls": stalls,
            "maxBranchSeen": max_seen, "exhausted": exhausted, "truncated": truncated}


def verdict(r):
    """四类判据,按特异性从高到低判定。"""
    if r["solvable"]:
        return "可解(证据)"
    if r["deadlocks"] > 0:
        return "死锁"
    if r["exhausted"] and not r["truncated"]:
        return "不可解(证明)"
    return "截断(未搜到)"


def main():
    spec = ashlib.load_spec()
    deep = copy.deepcopy(spec)
    deep["params"]["enemyHp"] = 999
    deep["params"]["turnLimit"] = 2
    fixtures = [("baseline", spec, 12, True), ("unsolvable", deep, 12, True),
                ("truncated", deep, 8, True), ("deadlock", spec, 12, False)]
    header = ["fixture", "solvable", "solLen", "nodes", "expanded", "deadlocks",
              "stalls", "exhausted", "truncated", "verdict"]
    rows = []
    for name, fx, steps, allow_e in fixtures:
        r = search(fx, max_steps=steps, allow_e=allow_e)
        rows.append([name, str(r["solvable"]).lower(), len(r["solution"]), r["nodes"],
                     r["expanded"], r["deadlocks"], r["stalls"], str(r["exhausted"]).lower(),
                     str(r["truncated"]).lower(), verdict(r)])
    n = ashlib.write_csv(os.path.join(ROOT, "out", "mech10_detection.csv"), header, rows)

    print(" ".join(header))
    for r in rows:
        print(" ".join(str(x) for x in r))
    print("wrote out/mech10_detection.csv rows=%d" % n)
    kinds = {r[0]: r[9] for r in rows}
    ok = (kinds["baseline"] == "可解(证据)" and kinds["unsolvable"] == "不可解(证明)"
          and kinds["truncated"] == "截断(未搜到)" and kinds["deadlock"] == "死锁")
    print("verdicts=%s" % kinds)
    print("allFourDistinct=%s" % ok)
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/gd_mech_10_detection.py:

fixture solvable solLen nodes expanded deadlocks stalls exhausted truncated verdict
baseline true 8 1517 912 0 0 false false 可解(证据)
unsolvable false 0 420 420 0 0 true false 不可解(证明)
truncated false 0 408 382 0 0 false true 截断(未搜到)
deadlock false 0 18 18 3 0 true false 死锁
wrote out/mech10_detection.csv rows=4
verdicts={'baseline': '可解(证据)', 'unsolvable': '不可解(证明)', 'truncated': '截断(未搜到)', 'deadlock': '死锁'}
allFourDistinct=True

四个夹具各命中一类,allFourDistinct=True。baseline 1517 个状态找到 8 步取胜序列;unsolvable(敌方 999 血 + 2 回合上限)穷尽 420 个状态后判不可解,exhausted=true;同一个夹具把深度压到 8 步,exhausted 翻成 false、truncated 翻成 true,结论降级为「没搜到」;deadlock 夹具摘掉 e,18 个状态里撞上 3 个真死锁。这四个数字并列,就是判据表存在的意义——同一个 solvable=false,配上不同的布尔组合,说的完全是两回事。

P 层把判据钉死在 solvability.ts 上,关键是「边界」那一条:

// snippet: core/test/gd_mech_10_detection.test.ts 的关键断言
const r = searchSolvable(unsolvable());          // 敌方 999 血 + 2 回合上限
assert.equal(r.solvable, false);
assert.equal(r.exhausted, true);                 // 前沿被穷尽 → 这是证明
assert.equal(r.truncated, false);

const base = searchSolvable(SPEC);
assert.equal(base.solvable, true);
assert.equal(runSpec(SPEC, base.solution).won, true);   // true 有证据,可重放

const short = searchSolvable(unsolvable(), { ...DEFAULT_LIMITS, maxSteps: 8 });
assert.equal(short.exhausted, false);
assert.equal(short.truncated, true);             // 撞上限 → 只能说「没搜到」

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

✔ 判据一:无合法动作即死锁,但本规则下 e 恒合法故默认不可达 (0.752417ms)
✔ 判据二:无进展循环即停滞——e 必然推进 turn,故基线恒 false (0.243833ms)
✔ 判据三:资源不可达目标即不可解,穷尽前沿后是证明 (5.0865ms)
✔ 证明边界:true 有证据恒可靠,false 撞上限时只能说没搜到 (16.138125ms)
✔ 基线分类:可解、死锁 0、停滞 0——判据一与判据二在本规则下不可达 (11.409458ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 241.324542

证明边界是这一节最不能含糊的一条:solvable === true 由一条取胜序列作证,无论上限多紧都成立,证据不受搜索预算影响;solvable === false 只有在 exhausted === true(前沿被穷尽)时才是证明,一旦 truncated === true,false 只意味着「限深内没搜到」。把这两个布尔分开返回,而不是合成一个 solvable,是 4.3 那个模块唯一不能省的设计——本节只是把它用起来。

死锁判据还要自证会响。本规则下 e 恒合法,所以死锁不可达;deadlock 夹具用的是动作集变体:acts_of(spec, s, allow_e=False) 把 e 摘掉,空手牌 + 0 能量就成了真死锁。这不是给规则开后门,而是给判据配一个能判 true 的负例——与 4.2、10.2 一脉相承:没有反面的判据等于没说。

判据的顺序也有讲究。verdict() 先判 solvable、再判 deadlocks、最后才判 exhausted,依据是特异性从高到低:一条取胜序列是最强证据,压过一切;「撞上了死锁状态」比「整体不可解」更具体,先报;只有前两者都不成立,才轮到「前沿穷尽」这种全局结论。顺序错了,deadlock 夹具会被误报成「不可解」,判据表就退化回一个布尔。这条规则值得写进任何检测器的设计里:先报最强的证据,再报最具体的发现,最后才是全局兜底。

四种判据的分工

判据报什么不该报什么
死锁某个状态走不动了不要报成「整局不可解」
停滞有动作但都是空操作不要与死锁混为一谈
不可解穷尽后无解(证明)不要把「没搜到」报成不可解
截断撞了上限不要报成任何确定性结论
检查项合格标准
判据分得清死锁 / 停滞 / 不可解 / 截断四种形态各有定义与检测方式
结论分级solvable / exhausted / truncated 三个布尔分开返回
边界写明true 有证据恒可靠;false 仅 exhausted 时是证明
判据会响死锁用动作集变体造出真负例,不停留在「不可达」
上限诚实撞上限即标 truncated,绝不把「没搜到」报成「不可解」

到这里,状态空间的三问就答完了:能走到哪(10.1 的可达集)、哪些状态不允许存在(10.2 的清单)、整张图通不通(10.3 的判据表)。三者都跑在命令行里,数字都是实跑的。下一章离开状态语义,回到数值本身:同一份规格要让 Python 与 TypeScript 给出逐位一致的结果,得先把所有预算与阈值整数化。

阅读导航:上一节:10.2 不变量清单 · 下一节:11.1 整数化与跨语言一致 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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