2.3 支配策略的修复

找到支配对只是诊断,修复才是设计动作。本节把「消除全部严格支配对」写成带约束的搜索目标——strictPairs==0 且最优策略仍能赢够局数,再扫 7 个候选旋钮:enemyHp 拉到 79 确实消掉支配,但全员必败(治死);最小可行修复是 energyMax 3→2,代价 1 步、最优胜局仍 15。

站内独立游戏平衡与经济设计 给的修复手段是调数值:把强的调弱、把弱的调强,靠经验和手感收敛。本节把「怎么修」变成一个带约束的搜索问题——它和那篇的目标一样(让选择重新有意义),但每一步都可复算、可比较代价。修复的判据不是「感觉平衡了」,而是「严格支配对归零且最优策略仍然赢」。

2.3 支配策略的修复

2.1 找出支配对、2.2 给了检测算法,但「一条策略被另一条处处压住」本身不是终点:如果被压住的策略本该是个有意义的选择,支配就是设计缺陷,得修。修复的目标要写死两条,缺一不可:

约束判据为什么
消除支配strictPairs == 0没有任何策略被别的策略处处压住
保住可玩最优策略胜局数 ≥ minWins(8)只消支配不保可玩就是「治死」

第二条是本节最容易被忽略、也最要紧的一条。把敌人血量一路拉高,确实能让所有策略都不支配彼此——但那是因为谁都赢不了,分数全塌到同一水平。这种「修复」把游戏治死了。所以修复不是单纯求 strictPairs==0 的最小改动,而是求同时满足两条约束的最小改动。

候选旋钮取 7 个:params 的 enemyHp / energyMax / enemyAttack / turnLimit,以及三张牌的字段 jab.damage / guard.block / strike.cost。每个旋钮扫一遍取值网格,代价记为 |value − baseline|,不做二分——因为支配计数不是参数的单调函数(enemyHp 65 剩 1 对、70 反而回到 2 对)。

S 层:最小改动的修复搜索

脚本对每个候选的每个取值重算一次支配矩阵,挑出满足两条约束、代价最小的修复。全整数,无浮点,无随机。

#!/usr/bin/env python3
# file: sim/gd_play_02_repair.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_02_repair.py
"""卷二 2.3 支配策略的修复(S 层):找一个最小的设计参数改动,消掉全部严格支配对。

修复目标(写进 config):strictPairs==0 且 最优策略胜局数 >= minWins。
只消支配不保可玩是「治死」——把敌人血量拉到全员必败也能让支配消失。
候选旋钮 = params 四项 + 三张牌的字段。对每个候选扫一遍取值网格:
  cost = |value − baseline|(改动步长);可行解按 cost 升序、并列时按最优胜局数降序取。
支配计数**不是**参数的单调函数,故不做二分,老老实实扫完整网格。
产出 out/play02_repair.csv + out/play02_repair.svg + out/play02_repair.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
from gd_play_02_dominance import payoff_matrix  # noqa: E402


def variant(spec, cand, value):
    s = copy.deepcopy(spec)
    if "param" in cand:
        s["params"][cand["param"]] = value
    else:
        for c in s["cards"]:
            if c["id"] == cand["card"]:
                c[cand["field"]] = value
    return s


def evaluate(spec, cfg, seeds):
    """返回 (严格支配对数, 最优策略胜局数, 存活策略数)。"""
    _, rows, win_rows = payoff_matrix(spec, cfg, seeds)
    n = len(rows)
    pairs = [(i, j) for i in range(n) for j in range(n)
             if i != j and all(x > y for x, y in zip(rows[i], rows[j]))]
    dominated = {j for _, j in pairs}
    return len(pairs), max(sum(w) for w in win_rows), n - len(dominated)


def baseline_value(spec, cand):
    return spec["params"][cand["param"]] if "param" in cand else \
        next(c[cand["field"]] for c in spec["cards"] if c["id"] == cand["card"])


def label(cand):
    return cand["param"] if "param" in cand else f"{cand['card']}.{cand['field']}"


def chart(rows, knob, path):
    root = ashlib.svg_begin(720, 300)
    ashlib.svg_text(root, 20, 26, f"修复扫描:{knob} 取值 → 严格支配对数", 14)
    ashlib.svg_text(root, 20, 46, "绿 = 严格支配对 0;灰 = 仍有支配(越高越坏)", 11, "#94a3b8")
    mx = max(r["strictPairs"] for r in rows) or 1
    bw = 620 // max(1, len(rows))
    for i, r in enumerate(rows):
        h = r["strictPairs"] * 180 // mx
        ET.SubElement(root, "rect", {
            "x": str(30 + i * bw), "y": str(250 - h), "width": str(max(bw - 8, 4)),
            "height": str(max(h, 1)), "fill": "#22c55e" if r["strictPairs"] == 0 else "#64748b"})
        ashlib.svg_text(root, 30 + i * bw + bw // 2, 266, str(r["value"]), 10, "#cbd5e1", "middle")
        ashlib.svg_text(root, 30 + i * bw + bw // 2, 248 - h, str(r["strictPairs"]), 10, "#e2e8f0", "middle")
    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, min_wins = cfg["seeds"], cfg["repair"]["minWins"]
    cands = [dict(c, kind="param") for c in cfg["repair"]["paramCandidates"]] + \
            [dict(c, kind="card") for c in cfg["repair"]["cardCandidates"]]
    csv, feasible, per_cand = [], [], {}
    for cand in cands:
        base, rows = baseline_value(spec, cand), []
        for v in cand["values"]:
            p, w, surv = evaluate(variant(spec, cand, v), cfg, seeds)
            cost = abs(v - base)
            rows.append({"value": v, "cost": cost, "strictPairs": p, "bestWins": w,
                         "survivors": surv, "ok": p == 0 and w >= min_wins})
            csv.append([label(cand), base, v, cost, p, w, surv, str(p == 0 and w >= min_wins).lower()])
        per_cand[label(cand)] = rows
        feasible += [{"label": label(cand), "baseline": base, **r} for r in rows if r["ok"]]
    feasible.sort(key=lambda r: (r["cost"], -r["bestWins"]))
    ashlib.write_csv(os.path.join(OUT, "play02_repair.csv"),
                     ["knob", "baseline", "value", "cost", "strictPairs", "bestWins", "survivors", "ok"], csv)
    report = {"id": "gd_play_02_repair", "seeds": len(seeds), "minWins": min_wins,
              "objective": cfg["repair"]["objective"], "feasible": feasible,
              "chosen": feasible[0] if feasible else None, "scans": per_cand,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "play02_repair.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"{'旋钮':<14}{'基线':>5}{'取值':>6}{'cost':>5}{'严格对':>7}{'最优胜':>7}{'存活':>5}  可行")
    for cand in cands:
        for r in per_cand[label(cand)]:
            print(f"{label(cand):<14}{baseline_value(spec, cand):>5}{r['value']:>6}{r['cost']:>5}"
                  f"{r['strictPairs']:>7}{r['bestWins']:>7}{r['survivors']:>5}  {'✓' if r['ok'] else ''}")
    print(f"\n可行修复 {len(feasible)} 个(strictPairs=0 且 最优胜局≥{min_wins}):")
    for f in feasible:
        print(f"  {f['label']} {f['baseline']}→{f['value']}  cost={f['cost']}  最优胜={f['bestWins']}")
    ch = feasible[0]
    size = chart(per_cand[ch["label"]], ch["label"], os.path.join(OUT, "play02_repair.svg"))
    print(f"选定修复:{ch['label']} {ch['baseline']}→{ch['value']}(cost={ch['cost']},"
          f"严格对 {ch['strictPairs']},最优胜 {ch['bestWins']})")
    print(f"svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play02_repair.csv out/play02_repair.svg out/play02_repair.json")
    ET.parse(os.path.join(OUT, "play02_repair.svg"))
    return 0


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

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

旋钮               基线    取值 cost    严格对    最优胜   存活  可行
enemyHp          50    50    0      5     16    3  
enemyHp          50    60   10      4     16    3  
enemyHp          50    65   15      1     16    4  
enemyHp          50    70   20      2     16    3  
enemyHp          50    79   29      0      0    5  
enemyHp          50    90   40      0      0    5  
energyMax         3     2    1      0     15    5  ✓
energyMax         3     3    0      5     16    3  
energyMax         3     4    1      7     16    3  
enemyAttack       6     6    0      5     16    3  
enemyAttack       6     8    2      5     16    3  
turnLimit        20     4   16      4     16    3  
turnLimit        20    20    0      5     16    3  
jab.damage        3     3    0      5     16    3  
jab.damage        3     7    4      4     16    4  
guard.block       5     0    5      3     16    4  
guard.block       5     5    0      5     16    3  
strike.cost       1     1    0      5     16    3  
strike.cost       1     2    1      0     10    5  ✓

可行修复 2 个(strictPairs=0 且 最优胜局≥8):
  energyMax 3→2  cost=1  最优胜=15
  strike.cost 1→2  cost=1  最优胜=10
选定修复:energyMax 3→2(cost=1,严格对 0,最优胜 15)
svg=1460 bytes  elapsed=46 ms
wrote out/play02_repair.csv out/play02_repair.svg out/play02_repair.json

判读:三种修复,两种是陷阱

类别例子严格对最优胜判读
治死enemyHp 50→7900支配没了,但全员必败——游戏被治死,不可行
无效enemyAttack 6→8、guard.block 5→05 / 316改了但支配没消(甚至更多),没修到点子上
可行energyMax 3→2015支配归零,最优策略仍赢 15/16——选定

enemyHp 那一列还有一处细节值得停一下:65 时只剩 1 对、70 时又回到 2 对。支配计数不是单调的,所以「二分找最小 enemyHp」是错的——65 会被当成答案,但它还有 1 对没消。正确做法是扫完整网格再挑,脚本正是这么做的。

可行解有两个(energyMax 3→2 与 strike.cost 1→2,代价都是 1),并列时按最优胜局数降序取,于是选定 energyMax 3→2:它把一回合的能量上限从 3 降到 2,重牌(费用 2)仍打得动,但「一回合连打两张 1 费牌」的余量没了,策略之间的差距被压平,支配消失而胜局仍高。

B 层:修复在留出集上的稳健性

修复是在训练种子上选出来的,必须拿到没见过的种子上复核,否则可能只是过拟合了那 16 个环境。脚本把选定修复与「治死」方案一并搬到留出集 testSeeds。

#!/usr/bin/env python3
# file: sim/gd_play_02_repair_robust.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_02_repair_robust.py
"""卷二 2.3 修复的稳健性(B 层):训练种子上选出的修复,能否在留出种子上成立。

在 config 的 seeds(训练)上取 2.3 S 层选定的修复旋钮与取值,搬到 testSeeds(留出)复核:
严格支配对是否仍为 0、最优策略胜局数是否仍 ≥ minWins。
同时把被否决的「治死」方案(enemyHp→79)一并搬到留出集,验证它确实把胜局打成 0。
判据:留出集上 strictPairs==0 且 bestWins>=minWins → 修复稳健;否则软告警。
永远 exit 0:B 档软告警不硬失败。产出 out/play02_repair_robust.json。
"""
from __future__ import annotations

import copy
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)
import ashlib  # noqa: E402
from gd_play_02_dominance import payoff_matrix  # noqa: E402

CHOSEN = {"knob": "energyMax", "value": 2}     # 2.3 S 层选定的修复
REJECTED = {"knob": "enemyHp", "value": 79}    # 被否决的「治死」方案


def apply_knob(spec, knob, value):
    s = copy.deepcopy(spec)
    if "." in knob:
        card, field = knob.split(".")
        for c in s["cards"]:
            if c["id"] == card:
                c[field] = value
    else:
        s["params"][knob] = value
    return s


def assess(spec, cfg, seeds):
    _, rows, win_rows = payoff_matrix(spec, cfg, seeds)
    n = len(rows)
    pairs = [(i, j) for i in range(n) for j in range(n)
             if i != j and all(x > y for x, y in zip(rows[i], rows[j]))]
    return len(pairs), max(sum(w) for w in win_rows)


def main() -> int:
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    train, test, min_wins = cfg["seeds"], cfg["testSeeds"], cfg["repair"]["minWins"]
    warnings, checks = [], []
    for label, kn, seeds in [("baseline", None, train), ("baseline", None, test),
                             ("chosen", CHOSEN, train), ("chosen", CHOSEN, test),
                             ("rejected", REJECTED, test)]:
        sp = spec if kn is None else apply_knob(spec, kn["knob"], kn["value"])
        pairs, best = assess(sp, cfg, seeds)
        checks.append({"set": "train" if seeds is train else "test", "variant": label,
                       "strictPairs": pairs, "bestWins": best,
                       "ok": pairs == 0 and best >= min_wins})
    rob = next(c for c in checks if c["variant"] == "chosen" and c["set"] == "test")
    if not rob["ok"]:
        warnings.append({"code": "REPAIR_NOT_ROBUST", "severity": "soft",
                         "message": f"修复在留出集上 strictPairs={rob['strictPairs']} "
                                    f"bestWins={rob['bestWins']},未达到目标"})
    rej = next(c for c in checks if c["variant"] == "rejected")
    if rej["bestWins"] > 0:
        warnings.append({"code": "REJECT_EXPECTATION", "severity": "soft",
                         "message": f"被否决方案在留出集上仍胜 {rej['bestWins']} 局,"
                                    f"「治死」判断需复核"})
    report = {"id": "gd_play_02_repair_robust", "tier": "B", "hardFail": False,
              "note": "B 档软告警:修复须在留出种子上仍消除支配且保住可玩",
              "minWins": min_wins, "chosen": CHOSEN, "rejected": REJECTED,
              "checks": checks, "warnings": warnings,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play02_repair_robust.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"{'集合':<7}{'方案':<10}{'严格对':>7}{'最优胜':>7}  达标")
    for c in checks:
        print(f"{c['set']:<7}{c['variant']:<10}{c['strictPairs']:>7}{c['bestWins']:>7}  {'✓' if c['ok'] else ''}")
    for w in warnings:
        print(f"SOFT WARN {w['code']}: {w['message']}")
    print(f"repairRobust={rob['ok']}  被否决方案留出集胜局={rej['bestWins']}  "
          f"warnings={len(warnings)}  hardFail=false  elapsed={report['elapsedMs']} ms")
    print("wrote out/play02_repair_robust.json")
    return 0


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

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

集合     方案            严格对    最优胜  达标
train  baseline        5     16  
test   baseline        3     16  
train  chosen          0     15  ✓
test   chosen          0     15  ✓
test   rejected        0      0  
repairRobust=True  被否决方案留出集胜局=0  warnings=0  hardFail=false  elapsed=10 ms
wrote out/play02_repair_robust.json

repairRobust=True:选定修复在留出集上 strictPairs=0、最优胜局 15,与训练集一致——修复泛化了,不是过拟合。对比行更有说服力:baseline 在训练集有 5 对、留出集只有 3 对(说明基线本身就带采样依赖,2.1 已论及),而修复在两处都归零;被否决的 enemyHp→79 在留出集上最优胜局仍是 0,「治死」判断在留出集上复现。B 层按纪律软告警不硬失败:warnings=0 表示没有风险要报,一旦修复不泛化就会报 REPAIR_NOT_ROBUST,但不会让构建失败。至此 2.1–2.3「定义 → 检测 → 修复」闭合;下一章换角度:不问「有没有支配」,而问「双方都最优会走到哪」。

自检表

检查项合格标准
目标双约束同时要求 strictPairs==0 与最优胜局 ≥ minWins
软告警不硬失败B 层永远 exit 0,不泛化只报警不阻断

阅读导航:上一节:2.2 检测算法与复杂度 · 下一节:3.1 minimax 与极小极大 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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