2.1 支配关系:严格与弱

支配策略是玩家迟早会发现、然后只用它的那条路——其余策略沦为摆设。本节给严格支配与弱支配的可判定定义,用《灰烬牌库》的固定下标策略族乘 16 个种子,算出 5 条严格支配对、7 条弱支配对、0 条支配策略,并验证严格支配对随种子集合单调增。

站内独立游戏平衡与经济设计 (约 1400 行)是一篇散文式平衡经验:讲数值怎么调、经济怎么稳,代码是 Unity csharp 片段、跑不起来,也没有「哪条策略更好」的可判定判据。本节不重复那些经验,而是把「一条策略比另一条更好」写成一条能逐环境核对的不等式——严格支配与弱支配。这是 MDA 里 D 层(动态)的第一个可判定性质:它回答的不是「这样玩爽不爽」,而是「这样玩是不是永远不亏」。

2.1 支配关系:严格与弱

策略空间里,玩家真正会选的往往只有几条。支配策略就是那种「无论环境怎么变都不比别的差」的策略——一旦存在,理性的玩家会收敛到它,其余策略就成了摆设。本节先把「不亏」精确定义成两种关系,再让仿真把它们算出来。

关系记号条件(∀ω 遍历环境集合 Ω)
严格支配s ≻ t对每个环境 ω,score(s,ω) > score(t,ω)
弱支配s ⪰ t对每个 ω 有 score(s,ω) ≥ score(t,ω),且至少一个 ω 取严格大于
支配策略—s 严格(或弱)支配所有其它策略
被支配策略—被某条策略严格(或弱)支配

两种关系的差别只在「能不能打平」:严格支配要求处处更优,弱支配允许有平局但不能全平。判定它们的唯一输入是一张得分矩阵——行是策略,列是环境。这里的 score 是代理指标(1000×won + playerHp,胜局优先、存活血量作次级),只用来做相对比较:说「s 支配 t」意思是逐格不等式成立,不是「s 的体验好 N 倍」。

策略族与环境。策略取固定下标族:每回合依次打「当前手牌第 k 张」4 次,再结束回合,共 5 回合——k 从 0 到 4 得到 5 条策略 slot0…slot4。它是卷一「策略即动作序列」的一个最简实例,可逐字重放。环境是种子:同一策略在不同洗牌下抽出不同手牌,得到不同的对局。策略与环境都写进只读配置,改口径只改配置。

{ "seeds": [20261001, "…", 20261016], "testSeeds": [20261101, "…", 20261116],
  "turns": 5, "slotsPerTurn": 4, "scoreScale": 1000,
  "strategies": [ {"name": "slot0", "handIndex": 0}, "…", {"name": "slot4", "handIndex": 4} ] }

S 层:把支配关系算出来

脚本建 5×16 的得分矩阵,再逐有序对做「处处更优」与「处处不差且有一处更优」两种检查。全整数,无浮点,无随机。

#!/usr/bin/env python3
# file: sim/gd_play_02_dominance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_02_dominance.py
"""卷二 2.1 支配关系:严格与弱(S 层)。

策略族 = 固定下标策略(每回合依次打「手牌第 k 张」4 次,再结束回合,共 5 回合)。
环境 Ω = 种子集合(同一策略在不同洗牌下得到不同对局)。得分是整数代理指标:
    score(s, ω) = 1000 * won(s, ω) + playerHp(s, ω)   # 胜局优先,存活血量为次级
严格支配:s ≻ t  ⇔  ∀ω, score(s,ω) > score(t,ω)
弱支配:  s ⪰ t  ⇔  ∀ω, score(s,ω) ≥ score(t,ω) 且 ∃ω, score(s,ω) > score(t,ω)
支配策略 = 严格(或弱)支配所有其它策略的策略;被支配策略 = 被某策略支配的策略。
score 是**代理指标**:只用于相对比较,不能写成「体验提升 N 倍」。
产出 out/play02_matrix.csv + out/play02_dominance.svg + out/play02_dominance.json。
全整数,无浮点,无 random。
"""
from __future__ import annotations

import copy
import json
import os
import sys
import time
import xml.etree.ElementTree as ET

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_02_dominance.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def strategy_seq(k: int, turns: int, slots: int) -> list:
    """固定下标策略:每回合打第 k 张牌 slots 次,再结束回合。"""
    out = []
    for _ in range(turns):
        out += ["p" + str(k)] * slots + ["e"]
    return out


def score_of(snap: dict, scale: int) -> int:
    return scale * (1 if snap["won"] else 0) + snap["playerHp"]


def payoff_matrix(spec: dict, cfg: dict, seeds: list) -> tuple:
    """行 = 策略,列 = 环境(种子)。返回 (names, rows, winRows)。"""
    names, rows, win_rows = [], [], []
    for st in cfg["strategies"]:
        seq = strategy_seq(st["handIndex"], cfg["turns"], cfg["slotsPerTurn"])
        row, wins = [], []
        for sd in seeds:
            s = copy.deepcopy(spec)
            s["seed"] = sd
            snap = ashlib.run_spec(s, seq)
            row.append(score_of(snap, cfg["scoreScale"]))
            wins.append(1 if snap["won"] else 0)
        names.append(st["name"])
        rows.append(row)
        win_rows.append(wins)
    return names, rows, win_rows


def strict_dom(a: list, b: list) -> bool:
    return all(x > y for x, y in zip(a, b))


def weak_dom(a: list, b: list) -> bool:
    return all(x >= y for x, y in zip(a, b)) and any(x > y for x, y in zip(a, b))


def relations(rows: list) -> tuple:
    n = len(rows)
    st = [(i, j) for i in range(n) for j in range(n) if i != j and strict_dom(rows[i], rows[j])]
    wk = [(i, j) for i in range(n) for j in range(n) if i != j and weak_dom(rows[i], rows[j])]
    strict_dominant = [i for i in range(n) if all(strict_dom(rows[i], rows[j]) for j in range(n) if j != i)]
    weak_dominant = [i for i in range(n) if all(weak_dom(rows[i], rows[j]) for j in range(n) if j != i)]
    dominated = sorted({j for _, j in st})
    return st, wk, strict_dominant, weak_dominant, dominated


def heatmap(names, rows, path):
    """得分矩阵热力图:列 = 种子,行 = 策略,色深 = 得分高低(相对 min/max)。"""
    lo = min(min(r) for r in rows)
    hi = max(max(r) for r in rows)
    cw, ch, x0, y0 = 40, 34, 96, 66
    W, H = x0 + cw * len(rows[0]) + 40, y0 + ch * len(names) + 48
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 16, 26, "得分矩阵:策略(行)× 种子(列)", 15)
    ashlib.svg_text(root, 16, 46, "色深 = 1000×won + playerHp(越亮越高);代理指标,仅用于相对比较", 11, "#94a3b8")
    for i, name in enumerate(names):
        y = y0 + i * ch
        ashlib.svg_text(root, 16, y + 22, name, 12)
        for j, v in enumerate(rows[i]):
            t = 0 if hi == lo else (v - lo) * 255 // (hi - lo)
            shade = 40 + t * 180 // 255
            ET.SubElement(root, "rect", {
                "x": str(x0 + j * cw + 2), "y": str(y + 3), "width": str(cw - 4),
                "height": str(ch - 6), "fill": f"#{shade:02x}9e{(255 - t):02x}", "rx": "3",
            })
    return ashlib.svg_save(root, path)


def main() -> int:
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    seeds = cfg["seeds"]
    names, rows, win_rows = payoff_matrix(spec, cfg, seeds)
    st, wk, sdom, wdom, dominated = relations(rows)

    csv = [[names[i]] + list(rows[i]) for i in range(len(names))]
    n = ashlib.write_csv(os.path.join(OUT, "play02_matrix.csv"),
                         ["strategy"] + [str(s) for s in seeds], csv)
    size = heatmap(names, rows, os.path.join(OUT, "play02_dominance.svg"))
    report = {"id": "gd_play_02_dominance", "seeds": len(seeds), "strategies": names,
              "scoreScale": cfg["scoreScale"], "strictPairs": st, "weakPairs": wk,
              "strictlyDominant": [names[i] for i in sdom],
              "weaklyDominant": [names[i] for i in wdom],
              "dominated": [names[i] for i in dominated],
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "play02_dominance.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"{'策略':<8}{'胜局':>5}  得分(16 个种子)")
    for i, name in enumerate(names):
        print(f"{name:<8}{sum(win_rows[i]):>5}  {rows[i]}")
    print(f"\n严格支配对 {len(st)}: " + " ".join(f"{names[i]}≻{names[j]}" for i, j in st))
    print(f"弱支配对   {len(wk)}: " + " ".join(f"{names[i]}⪰{names[j]}" for i, j in wk))
    print(f"严格支配策略={[names[i] for i in sdom]}  弱支配策略={[names[i] for i in wdom]}")
    print(f"被支配策略={[names[i] for i in dominated]}  未被支配={[names[i] for i in range(len(names)) if i not in dominated]}")
    print(f"csv={n} rows  svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play02_matrix.csv out/play02_dominance.svg out/play02_dominance.json")
    ET.parse(os.path.join(OUT, "play02_dominance.svg"))
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_02_dominance.py:

策略         胜局  得分(16 个种子)
slot0      16  [1047, 1048, 1053, 1053, 1053, 1047, 1053, 1048, 1052, 1053, 1052, 1052, 1053, 1048, 1053, 1053]
slot1      16  [1053, 1048, 1047, 1048, 1053, 1052, 1048, 1048, 1048, 1053, 1047, 1048, 1053, 1048, 1052, 1048]
slot2      16  [1048, 1048, 1048, 1042, 1047, 1052, 1048, 1048, 1053, 1053, 1047, 1053, 1048, 1048, 1048, 1048]
slot3      15  [1042, 1042, 1042, 1042, 1046, 36, 1047, 1042, 1041, 1042, 1047, 1042, 1042, 1042, 1047, 1047]
slot4       0  [30, 40, 30, 30, 35, 35, 35, 30, 35, 30, 30, 30, 30, 35, 35, 35]

严格支配对 5: slot0≻slot3 slot0≻slot4 slot1≻slot4 slot2≻slot4 slot3≻slot4
弱支配对   7: slot0⪰slot3 slot0⪰slot4 slot1⪰slot3 slot1⪰slot4 slot2⪰slot3 slot2⪰slot4 slot3⪰slot4
严格支配策略=[]  弱支配策略=[]
被支配策略=['slot3', 'slot4']  未被支配=['slot0', 'slot1', 'slot2']
csv=5 rows  svg=6910 bytes  elapsed=6 ms
wrote out/play02_matrix.csv out/play02_dominance.svg out/play02_dominance.json

判读:三种策略三种命运

slot0、slot1、slot2 两两互不支配(谁在某几个种子上更高、在另几个更低),slot3 与 slot4 则被压住:

策略胜局严格被支配读法
slot0 / slot1 / slot216 / 16 / 16否都在「未被支配」集合里,谁也吃不掉谁
slot315是(被 slot0)只有一个种子上掉到 36 分(第 6 个种子那一局败了)
slot40是(被全部 4 条)全程只打最后一张牌,回合数拖到 6,一局都赢不了

两个结论要分开说。其一,不存在支配策略:没有任何一条策略支配所有其它策略——slot0 与 slot1 互有胜负,所以策略空间没有被「解死」。其二,slot4 被彻底支配:它在每个种子上的得分都低于另外四条,理性玩家永远不会选它。这就是「被支配策略」的用处——它可以从策略空间里删掉而不影响最优解,2.2 会把它做成算法。

B 层:支配对种子集合的稳健性

支配是「对所有环境成立」的性质,所以它天然依赖环境集合的规模。这里有一条单调性:若 s 在 Ω 上严格支配 t,则在任何子集 Ω’ ⊆ Ω 上也严格支配 t(约束更少)。反过来不成立——小集合上冒出来的支配对,放大集合后可能消失,那是采样假象。脚本把 16 个种子切成 6 个子集逐个复核。

#!/usr/bin/env python3
# file: sim/gd_play_02_dominance_stability.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_02_dominance_stability.py
"""卷二 2.1 支配关系的稳健性(B 层):严格支配对种子集合的单调性。

单调性事实:Ω' ⊆ Ω 时,s 在 Ω 上严格支配 t ⟹ s 在 Ω' 上也严格支配 t(约束更少)。
反过来不成立:小集合上出现的严格支配对,放大集合后可能消失——那是**采样假象**。
本脚本复用 2.1 S 层的 payoff_matrix / strict_dom,把 16 个种子切成 6 个子集逐个复核。
永远 exit 0:B 档软告警不硬失败。产出 out/play02_stability.json。
"""
from __future__ import annotations

import json
import os
import sys
import time

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_02_dominance.json")
sys.path.insert(0, HERE)
from gd_play_02_dominance import payoff_matrix, strict_dom  # noqa: E402


def strict_pairs(rows):
    n = len(rows)
    return {(i, j) for i in range(n) for j in range(n) if i != j and strict_dom(rows[i], rows[j])}


def main() -> int:
    t0 = time.perf_counter_ns()
    spec = __import__("ashlib").load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    seeds = cfg["seeds"]
    names = [s["name"] for s in cfg["strategies"]]
    full = strict_pairs(payoff_matrix(spec, cfg, seeds)[1])
    subsets = {"first8": seeds[:8], "last8": seeds[8:], "even8": seeds[0::2],
               "odd8": seeds[1::2], "first4": seeds[:4], "last4": seeds[-4:]}
    rows_out, warnings = [], []
    for label, sub in subsets.items():
        sp = strict_pairs(payoff_matrix(spec, cfg, sub)[1])
        missing, artifacts = full - sp, sp - full
        for i, j in sorted(artifacts):
            warnings.append({"code": "SAMPLING_ARTIFACT", "severity": "soft", "subset": label,
                             "pair": [names[i], names[j]],
                             "message": f"子集 {label}({len(sub)} 种子)里 {names[i]}≻{names[j]},"
                                        f"放大到 {len(seeds)} 种子后消失:小样本假象"})
        rows_out.append({"subset": label, "seeds": len(sub), "strictCount": len(sp),
                         "missing": [[names[i], names[j]] for i, j in sorted(missing)],
                         "artifacts": [[names[i], names[j]] for i, j in sorted(artifacts)]})
    report = {"id": "gd_play_02_dominance_stability", "tier": "B", "hardFail": False,
              "note": "B 档软告警:严格支配对随种子集合单调增;子集独有对是采样假象",
              "fullSeeds": len(seeds), "fullStrictCount": len(full),
              "fullPairs": [[names[i], names[j]] for i, j in sorted(full)],
              "subsets": rows_out, "warnings": warnings,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play02_stability.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"全集 {len(seeds)} 种子:严格支配对 {len(full)} "
          + " ".join(f"{names[i]}≻{names[j]}" for i, j in sorted(full)))
    print(f"{'子集':<8}{'种子':>5}{'严格对':>7}  假象(子集有全集无)")
    for r in rows_out:
        arts = " ".join(f"{a}≻{b}" for a, b in r["artifacts"]) or "-"
        print(f"{r['subset']:<8}{r['seeds']:>5}{r['strictCount']:>7}  {arts}")
    for w in warnings:
        print(f"SOFT WARN {w['code']}: {w['message']}")
    print(f"monotoneOK={all(not r['missing'] for r in rows_out)}  "
          f"artifacts={len(warnings)}  hardFail=false  elapsed={report['elapsedMs']} ms")
    print("wrote out/play02_stability.json")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_02_dominance_stability.py:

全集 16 种子:严格支配对 5 slot0≻slot3 slot0≻slot4 slot1≻slot4 slot2≻slot4 slot3≻slot4
子集         种子    严格对  假象(子集有全集无)
first8      8      6  slot1≻slot3
last8       8      5  -
even8       8      5  -
odd8        8      6  slot1≻slot3
first4      4      6  slot1≻slot3
last4       4      7  slot1≻slot3 slot2≻slot3
SOFT WARN SAMPLING_ARTIFACT: 子集 first8(8 种子)里 slot1≻slot3,放大到 16 种子后消失:小样本假象
SOFT WARN SAMPLING_ARTIFACT: 子集 odd8(8 种子)里 slot1≻slot3,放大到 16 种子后消失:小样本假象
SOFT WARN SAMPLING_ARTIFACT: 子集 first4(4 种子)里 slot1≻slot3,放大到 16 种子后消失:小样本假象
SOFT WARN SAMPLING_ARTIFACT: 子集 last4(4 种子)里 slot1≻slot3,放大到 16 种子后消失:小样本假象
SOFT WARN SAMPLING_ARTIFACT: 子集 last4(4 种子)里 slot2≻slot3,放大到 16 种子后消失:小样本假象
monotoneOK=True  artifacts=5  hardFail=false  elapsed=7 ms
wrote out/play02_stability.json

monotoneOK=True 说明 5 条全集支配对在每个子集里都还在(单调性成立);5 条 SAMPLING_ARTIFACT 则说明只用 4 个种子就会多判出 2 条假的支配对。B 层按纪律是软告警不硬失败:它提示「这个支配结论对样本量敏感」,而不是让构建失败。教训直接可用:支配检测的环境集合宁多勿少,否则会把采样噪声当成设计缺陷。

自检表

检查项合格标准
定义可判定严格 / 弱支配写成逐环境不等式,不是形容词
环境集合入档种子写进 config,结论与 Ω 绑定
代理指标显式score 声明为代理指标,只做相对比较、禁绝对化
软告警不硬失败B 层永远 exit 0,只列风险不阻断

支配关系立起来之后,下一节把它做成算法:怎样在可接受的时间内,从一张大得多的得分矩阵里把被支配策略全部找出来,以及这个代价的复杂度上界。

阅读导航:上一节:1.3 策略空间的规模控制 · 下一节:2.2 检测算法与复杂度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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