3.1 minimax 与极小极大

零和博弈里玩家想最大化最坏结果(maximin),对手想最小化最好结果(minimax);两者相等时存在纯策略鞍点,即纯策略纳什均衡。本节把《灰烬牌库》的 4 张牌 × 5 种敌人架势写成整数收益矩阵,实跑得到 maximin=−6、minimax=−3、间隙 3、鞍点 0 个——这张矩阵没有纯策略均衡。

站内《数值平衡与商业化运营》 那一套处理的是 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 列时,玩家自由选行能达到的最大值摆这一式的最好结果
maximinmax_i rowmin(i)玩家单方面能保证的下界(安全线)
minimaxmin_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)−70

这里 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 混合策略与纳什直觉 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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