站内《数值平衡与商业化运营》 那一套处理的是 SLG 的数值曲线、赛季膨胀与付费定价——它问「一个数值随时间该涨多少、卖多少钱」,是商业化与运营口径;本章问的是一个完全不同的问题:当对手也会思考时,最优是什么。这是博弈论(game theory)而不是数值调优:极小极大(minimax)、混合策略、最优解的计算代价。本节起,卷二从「策略空间有多大」转向「策略空间里的最优点在哪、对手会不会把它抢走」。
1.2 策略空间枚举与 Shannon 熵 把《灰烬牌库》的策略空间数清楚了,2.3 支配策略的修复 处理了「某张牌怎么打都最优」的退化。那两节都假设对手是固定的——敌人每回合机械地打 6 点。本节给敌人装上大脑:让它也选,并问「玩家能保证的最低收益是多少」。
3.1 minimax 与极小极大
一个零和博弈(zero-sum game)写成矩阵:行是玩家的一招,列是对手的一招,格子里是玩家拿到的收益(对手拿它的相反数,两者之和恒为 0)。玩家想最大化,对手想最小化。纯策略(pure strategy)指双方各自确定地选一招,不做随机。
在这张矩阵上,有两个方向相反的数:
| 量 | 定义 | 直觉 |
|---|---|---|
rowmin(i) | 玩家选第 i 行时,对手自由选列能压到的最小值 | 出这一招的最坏结果 |
colmax(j) | 对手选第 j 列时,玩家自由选行能达到的最大值 | 摆这一式的最好结果 |
maximin | max_i rowmin(i) | 玩家单方面能保证的下界(安全线) |
minimax | min_j colmax(j) | 对手单方面能压到的上界(封锁线) |
两个量之间有一条铁律:maximin ≤ minimax。玩家先想「对手会怎么压我」再选招,对手先想「玩家会怎么打我」再选式——双方都往最坏处打算,谁也没占到便宜,于是安全线永远不高于封锁线。当两者相等时,矩阵存在一个鞍点(saddle point):那一格既是所在行的最小值、又是所在列的最大值,双方都不愿单方面离开——这就是纯策略纳什均衡。鞍点存在与否,决定了后面两节要不要引入随机化。
把机制写成一张零和矩阵
《灰烬牌库》原本是单人对固定敌人,要谈均衡就得先给敌人一套可选的架势。架势与收益定义写进配置,改口径只改配置、不改脚本。
{
"id": "gd_play_03_matrix",
"note": "卷二 3.1/3.2 均衡与最优:4 张牌 x 5 种敌人架势的零和收益矩阵(全整数,禁浮点)",
"payoffNote": "payoff(i,j) = max(0, cardDamage_i - stanceBlock_j) - max(0, stanceAttack_j - cardBlock_i)",
"playerActions": [
{ "id": "strike", "name": "打击" },
{ "id": "heavy", "name": "重击" },
{ "id": "jab", "name": "刺击" },
{ "id": "guard", "name": "格挡" }
],
"enemyStances": [
{ "id": "allout", "name": "强攻", "attack": 12, "block": 0 },
{ "id": "counter", "name": "反击", "attack": 8, "block": 12 },
{ "id": "hold", "name": "稳守", "attack": 3, "block": 4 },
{ "id": "feint", "name": "佯攻", "attack": 5, "block": 3 },
{ "id": "charge", "name": "蓄力", "attack": 9, "block": 6 }
],
"nodeBudget": 10000000
}
玩家的一招就是打一张牌,伤害与格挡直接取自只读真源 config/play_spec.json 的 cards[];敌人的一招是摆一种架势,attack / block 来自上面的配置。收益按「先算实际造成的伤害,再减去实际承受的伤害」:
| 记号 | 含义 | 取值 |
|---|---|---|
d_i | 玩家第 i 张牌的伤害 | strike=6 / heavy=14 / jab=3 / guard=0 |
b_i | 玩家第 i 张牌的格挡 | strike=0 / heavy=0 / jab=0 / guard=5 |
attack_j | 敌人第 j 种架势的攻击 | 强攻 12 / 反击 8 / 稳守 3 / 佯攻 5 / 蓄力 9 |
block_j | 敌人第 j 种架势的格挡 | 强攻 0 / 反击 12 / 稳守 4 / 佯攻 3 / 蓄力 6 |
payoff(i,j) | 净伤害 = max(0, d_i − block_j) − max(0, attack_j − b_i) | 整数,可正可负 |
max(0, ·) 是先扣格挡、扣不满记 0,与 run_spec 里 min(card.damage, enemyHp) 的截断是同一类整数运算。全套无浮点、无随机。
S 层:算 maximin 与 minimax
脚本读真源与配置,铺出 4×5 矩阵,再算 rowmin / colmax / maximin / minimax,并扫出所有鞍点。输出一张热力图(纯 xml.etree SVG)与一份 JSON。
#!/usr/bin/env python3
# file: sim/gd_play_03_minimax.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_03_minimax.py
"""卷二 3.1 minimax 与极小极大(S 层):把《灰烬牌库》写成一张零和收益矩阵。
玩家纯策略 = 4 张牌(strike / heavy / jab / guard,伤害与格挡取自 play_spec.json);
敌人纯策略 = 5 种架势(attack / block 取自 config/gd_play_03_matrix.json)。
收益(玩家视角):payoff(i,j) = 实际伤害 − 实际承伤
= max(0, cardDamage_i − stanceBlock_j) − max(0, stanceAttack_j − cardBlock_i)
全整数、无浮点、无随机。
计算四个量:
rowmin(i) = min_j payoff(i,j) 玩家打 i 时的最坏结果(对手自由选架势)
colmax(j) = max_i payoff(i,j) 敌人摆 j 时的最好结果(玩家自由选牌)
maximin = max_i rowmin(i) 玩家能单方面保证的下界(安全线)
minimax = min_j colmax(j) 敌人能单方面压到的上界(封锁线)
恒有 maximin <= minimax;两者相等时矩阵存在纯策略鞍点,即纯策略纳什均衡。
产出 out/play03_minimax.json + out/play03_minimax.svg。
"""
from __future__ import annotations
import json
import os
import sys
import xml.etree.ElementTree as ET
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_03_matrix.json")
def payoff_matrix(spec: dict, cfg: dict):
"""零和收益矩阵:行 = 玩家出牌,列 = 敌人架势,格 = 净伤害(全整数)。"""
stances = cfg["enemyStances"]
rows = []
for c in spec["cards"]:
row = []
for s in stances:
dealt = max(0, c["damage"] - s["block"]) # 敌方格挡先吃伤害
taken = max(0, s["attack"] - c["block"]) # 我方格挡先吃承伤
row.append(dealt - taken)
rows.append({"id": c["id"], "damage": c["damage"], "block": c["block"], "payoff": row})
return rows
def analyze(rows, stances):
"""rowmin / colmax / maximin / minimax / 鞍点集合,全部整数比较。"""
ncol = len(stances)
rowmin = [min(r["payoff"]) for r in rows]
colmax = [max(r["payoff"][j] for r in rows) for j in range(ncol)]
maximin = max(rowmin)
minimax = min(colmax)
saddles = [(r["id"], stances[j]["id"])
for r in rows for j in range(ncol)
if r["payoff"][j] == rowmin[rows.index(r)] == colmax[j]]
return rowmin, colmax, maximin, minimax, saddles
def heatmap(rows, stances, rowmin, colmax, maximin, minimax, saddles, path):
"""矩阵热力图:红 = 负、绿 = 正、灰 = 零;行尾标 rowmin、列底标 colmax。"""
cw, ch, x0, y0 = 66, 30, 168, 92
W, H = x0 + cw * len(stances) + 90, y0 + ch * (len(rows) + 1) + 44
root = ashlib.svg_begin(W, H)
ashlib.svg_text(root, 16, 30, "零和收益矩阵:4 张牌 × 5 种敌人架势", 15)
ashlib.svg_text(root, 16, 50,
f"maximin={maximin} minimax={minimax} 间隙={minimax - maximin}"
f" 鞍点={len(saddles)} 个", 12, "#94a3b8")
for j, s in enumerate(stances):
ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 - 12,
f"{s['name']}", 11, "#cbd5e1", "middle")
ashlib.svg_text(root, x0 + j * cw + cw // 2, y0 - 26,
f"{s['attack']}/{s['block']}", 10, "#64748b", "middle")
ashlib.svg_text(root, x0 + len(stances) * cw + 30, y0 - 12, "rowmin", 11, "#cbd5e1", "middle")
for i, r in enumerate(rows):
y = y0 + i * ch
ashlib.svg_text(root, 16, y + 20, f"{r['name'] if 'name' in r else r['id']} "
f"({r['damage']}/{r['block']})", 11)
for j in range(len(stances)):
v = r["payoff"][j]
fill = "#334155" if v == 0 else ("#22c55e" if v > 0 else "#ef4444")
ET.SubElement(root, "rect", {
"x": str(x0 + j * cw + 3), "y": str(y + 3), "width": str(cw - 6),
"height": str(ch - 6), "fill": fill, "rx": "4", "data-value": str(v),
})
ashlib.svg_text(root, x0 + j * cw + cw // 2, y + 20, f"{v:+d}", 12, "#0f172a", "middle")
ashlib.svg_text(root, x0 + len(stances) * cw + 30, y + 20,
f"{rowmin[i]:+d}", 12, "#facc15", "middle")
yb = y0 + len(rows) * ch
ashlib.svg_text(root, 16, yb + 20, "colmax", 11, "#cbd5e1")
for j in range(len(stances)):
ashlib.svg_text(root, x0 + j * cw + cw // 2, yb + 20, f"{colmax[j]:+d}", 12, "#facc15", "middle")
return ashlib.svg_save(root, path)
def main() -> int:
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
stances = cfg["enemyStances"]
rows = payoff_matrix(spec, cfg)
for r, a in zip(rows, cfg["playerActions"]):
r["name"] = a["name"]
rowmin, colmax, maximin, minimax, saddles = analyze(rows, stances)
print("== 零和收益矩阵(行=玩家出牌,列=敌人架势,格=净伤害)==")
head = "".join(f"{s['name']:>7}" for s in stances)
print(f"{'牌':<12}{head}{'rowmin':>8}")
for i, r in enumerate(rows):
cells = "".join(f"{v:>+7}" for v in r["payoff"])
print(f"{r['name']}({r['damage']}/{r['block']})".ljust(12) + cells + f"{rowmin[i]:>+8}")
print(f"{'colmax':<12}" + "".join(f"{v:>+7}" for v in colmax))
print(f"\nmaximin = max rowmin = {maximin}")
print(f"minimax = min colmax = {minimax}")
print(f"间隙 minimax - maximin = {minimax - maximin}")
print(f"纯策略鞍点 = {len(saddles)} 个 {saddles}")
assert maximin <= minimax, "maximin 必须 <= minimax"
size = heatmap(rows, stances, rowmin, colmax, maximin, minimax, saddles,
os.path.join(OUT, "play03_minimax.svg"))
report = {
"id": "gd_play_03_matrix", "players": [r["id"] for r in rows],
"stances": [s["id"] for s in stances],
"matrix": [[r["payoff"][j] for j in range(len(stances))] for r in rows],
"rowmin": rowmin, "colmax": colmax,
"maximin": maximin, "minimax": minimax, "gap": minimax - maximin,
"saddles": saddles,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "play03_minimax.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"svg={size} bytes")
print("wrote out/play03_minimax.json out/play03_minimax.svg")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_03_minimax.py:
== 零和收益矩阵(行=玩家出牌,列=敌人架势,格=净伤害)==
牌 强攻 反击 稳守 佯攻 蓄力 rowmin
打击(6/0) -6 -8 -1 -2 -9 -9
重击(14/0) +2 -6 +7 +6 -1 -6
刺击(3/0) -9 -8 -3 -5 -9 -9
格挡(0/5) -7 -3 +0 +0 -4 -7
colmax +2 -3 +7 +6 -1
maximin = max rowmin = -6
minimax = min colmax = -3
间隙 minimax - maximin = 3
纯策略鞍点 = 0 个 []
svg=7774 bytes
wrote out/play03_minimax.json out/play03_minimax.svg
判读:安全线在 −6,封锁线在 −3
maximin = −6 的落点是重击。 重击(14 伤害 / 0 格挡)在最坏的架势下仍能拿到 −6:对手摆「反击」(8 攻 / 12 格挡)时,重击的 14 点伤害被 12 格挡吃掉只剩 2,自己又挨 8 点,净 −6。玩家能单方面保证的,就是这条 −6 的安全线——不管对手怎么变,只要打重击,净收益不会比 −6 更差。
minimax = −3 的落点是反击。 反击(8 攻 / 12 格挡)在玩家最好的应对下把收益压到 −3:玩家改打格挡(0 伤害 / 5 格挡)时,0 伤害全被 12 格挡吃光,自己只挨 3 点(8 − 5),净 −3。对手能单方面压到的,就是这条 −3 的封锁线。
两条线不重合:安全线 −6 比封锁线 −3 低 3。这个「间隙 3」意味着矩阵里没有一格同时是行最小与列最大——鞍点 0 个,纯策略纳什均衡不存在。玩家不能靠「我就打重击」保证到 −3,因为对手会立刻摆反击把他压到 −6;对手也不能靠「我就摆反击」封锁到 −3,因为玩家会改打格挡。双方都在猜对方,谁也不肯先定死一招。
| 观察 | 数值 | 含义 |
|---|---|---|
maximin | −6(重击) | 玩家确定性地打,能保证的最差也最好的结果 |
minimax | −3(反击) | 对手确定性地摆,能把玩家压到的最上限 |
| 间隙 | 3 | 两个确定性方案的落差,就是随机化能抢回来的空间 |
| 鞍点 | 0 个 | 不存在纯策略均衡,必须引入混合策略 |
| 格挡这一行 | [−7,−3,0,0,−4] | 唯一有非负格(稳守/佯攻下净 0)的行——它的价值在后面 |
格挡这一行值得单独看一眼:它在「稳守」和「佯攻」两种低攻架势下净收益是 0,是整张表里唯一能「不亏」的落点。但格挡对高攻架势(强攻 −7、蓄力 −4)又会输得很惨。一招在某种架势下最好、在另一种架势下最差,这正是混合策略要利用的结构——没有哪一招能通吃。
鞍点存在的那个简单情形
为了看清「间隙 0」长什么样,把矩阵砍到只剩两行两列:
| 玩家 \ 敌人 | 强攻(12/0) | 稳守(3/4) |
|---|---|---|
| 重击(14/0) | +2 | +7 |
| 格挡(0/5) | −7 | 0 |
这里 rowmin = [2, −7]、colmax = [2, 7],于是 maximin = 2、minimax = 2——间隙 0,鞍点在 (重击, 强攻):那一格 +2 既是重击行的最小、又是强攻列的最大。玩家认准重击、对手认准强攻,谁单方面改都会更差。纯策略均衡存在当且仅当间隙为 0,而这个「两行两列恰好有鞍点」的巧合在大矩阵里被更多的架势破坏掉了——5 种架势一铺开,间隙就从 0 涨到 3。
自检表
| 检查项 | 合格标准 |
|---|---|
| 矩阵整数 | 所有格子是 max(0,·)−max(0,·) 的整数结果,无浮点 |
| 下界方向 | 脚本断言 maximin <= minimax,恒真 |
| 鞍点判定 | 一格同时是行最小与列最大才算鞍点,不用近似 |
| 口径可改 | 架势与收益定义在 config/gd_play_03_matrix.json,脚本不硬编码 |
| 结论诚实 | 本例鞍点 0 个,如实报「无纯策略均衡」,不硬凑一个解 |
没有纯策略均衡,不等于「这局没得打」。下一节让双方掷骰子——按一定概率混着出招,把 −6 到 −3 之间的间隙补上,并把「混合到什么比例」算成精确分数。
阅读导航:上一节:2.3 支配策略的修复 · 下一节:3.2 混合策略与纳什直觉 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。