5.2 平衡的度量

四层结论互相打架时,得把平衡压成一组可比的整数。本节给五个分量——胜率带宽、策略胜率熵、支配缺失度、可用策略数、互有胜负对数——再合成一个复合平衡分,全部是千分比整数。实跑《灰烬牌库》得到 band=703‰、熵=914‰、domFree=166‰、可用 2/6、复合分 605‰,并验证复合分对采样规模不敏感、但分量对容差敏感。

上一节把平衡拆成四层,四层却给出互相打架的结论——「平衡」要能比较,就得先压成数。站内经济平衡的概念与示意代码 算的是经济系统的收支水位(产出、消耗、通胀),那是资源口径;本节算的是策略胜率分布的形状,是博弈口径。两者都在说「平衡」,量的却不是同一样东西。

5.2 平衡的度量

把平衡做成一个数,要先想清楚「什么算好」。一条自然的要求是:没有哪条打法明显更强。这句话可以拆成几个互相独立的量,每个都能从上一节的胜率矩阵里直接数出来。

分量定义越平衡的方向
band 带宽策略行均值的极差(‰)越小越好
entropy 熵行均值归一化后的 Shannon 熵 ×1000越大越好
domFree 支配缺失度没被任何策略支配的策略占比(‰)越大越好
viableCount 可用策略数行均值 ≥ 最高 − 容差 的策略数越多越好
mutualPairs 互有胜负对数互有胜负的策略对越多越好
balanceScore 复合分(1000 − band + entropy) // 2越大越好

熵用行均值归一化后再除以 log2(策略数),所以它的满分是 1000‰——六条打法胜率完全相等时取满。所有分量都是代理指标:只做配置之间的相对比较,禁绝对化。复合分也不是唯一写法,这里让带宽与熵各占一半权重;换权重排序会变,所以比较两套配置必须用同一个公式与同一个容差。

{ "seeds": [401, "…", 416], "seedCounts": [4, 8, 12, 16],
  "tolerances": { "viableTol": 200, "bandBalanced": 200 } }

S 层:把五个分量数出来

脚本把上一节的胜率矩阵重算一遍,再逐项数出分量。熵只在脚本内用 math.log2 求,落盘一律取整——配置与产物里不允许出现浮点。支配检测的完整算法见 2.2,这里只读它的结果计数。

#!/usr/bin/env python3
# file: sim/gd_play_05_metric.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_05_metric.py
"""卷二 5.2 平衡的度量(S 层):把 5.1 的四个层次压成一组可比的整数指标。

五个分量 + 一个复合分(全部千分比整数,禁浮点):band 胜率带宽 · entropy 策略胜率熵
(行均值归一化后的 Shannon 熵 ÷ log2(策略数) ×1000)· domFree 支配缺失度 · viableCount
可用策略数(行均值 ≥ 最高 − 容差且 > 0)· mutualPairs 互有胜负对数 ·
balanceScore = (1000 − band + entropy) // 2。熵只在脚本内用 math.log2 求,落盘一律取整。
支配检测的完整算法见 2.2,本节只读它的结果计数。产出 out/play05_metric.json + .svg。
所有分量都是**代理指标**:只用于相对比较,禁绝对化。
"""
import copy, json, math, os, sys, 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_05_metric.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
KEYS = ("enemyHp", "turnLimit", "enemyAttack")


def choose(rule, s, cost, dmg, blk, ea):
    """确定性策略规则:读当前快照,返回下一步命令(pK 出第 K 张,e 结束回合)。"""
    hand, en = s["hand"], s["energy"]
    aff = [i for i, c in enumerate(hand) if cost[c] <= en]
    if not aff:
        return "e"
    if rule == "max_damage":
        i = max(aff, key=lambda i: (dmg[hand[i]], -cost[hand[i]]))
    elif rule == "block_first":
        g = [i for i in aff if blk[hand[i]] > 0]
        i = g[0] if g and s["block"] < ea else max(aff, key=lambda i: dmg[hand[i]])
    elif rule == "min_cost":
        i = min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))
    elif rule == "even_turn_big":
        big = [i for i in aff if cost[hand[i]] >= 2]
        if s["turn"] % 2 == 0:
            return f"p{big[0]}" if big else "e"
        i = min(aff, key=lambda i: (cost[hand[i]], -dmg[hand[i]]))
    elif rule == "first_slot":
        i = aff[0]
    else:
        i = aff[-1]
    return f"p{i}"


def matrix(spec, cfg, seeds):
    """W[policy][encounter] = 胜场千分比(整数地板除)。"""
    cost = {c["id"]: c["cost"] for c in spec["cards"]}
    dmg = {c["id"]: c["damage"] for c in spec["cards"]}
    blk = {c["id"]: c["block"] for c in spec["cards"]}
    W = {}
    for pol in cfg["policies"]:
        row = []
        for enc in cfg["encounters"]:
            wins = 0
            for sd in seeds:
                s = copy.deepcopy(spec)
                s["seed"] = sd
                s["params"].update({k: enc[k] for k in KEYS})
                seq = []
                for _ in range(300):
                    sn = ashlib.run_spec(s, seq)
                    if sn["over"]:
                        break
                    seq.append(choose(pol["rule"], sn, cost, dmg, blk, enc["enemyAttack"]))
                wins += 1 if ashlib.run_spec(s, seq)["won"] else 0
            row.append(1000 * wins // len(seeds))
        W[pol["id"]] = row
    return W


def metrics(W, ids, tol):
    means = {k: sum(W[k]) // len(W[k]) for k in ids}
    band = max(means.values()) - min(means.values())
    tot = sum(means.values())
    ent = 0
    if tot > 0:
        h = sum(-(v / tot) * math.log2(v / tot) for v in means.values() if v > 0)
        ent = int(round(1000 * h / math.log2(len(ids))))
    dom = lambda a, b: all(W[a][i] >= W[b][i] for i in range(len(W[a]))) \
        and any(W[a][i] > W[b][i] for i in range(len(W[a])))
    dominant = sorted(a for a in ids if any(dom(a, b) for b in ids if b != a))
    dominated = sorted(b for b in ids if any(dom(a, b) for a in ids if a != b))
    best = max(means.values())
    viable = sum(1 for v in means.values() if v > 0 and v >= best - tol)
    mutual = sum(1 for i in range(len(ids)) for j in range(i + 1, len(ids))
                 if any(W[ids[i]][k] > W[ids[j]][k] for k in range(len(W[ids[i]])))
                 and any(W[ids[i]][k] < W[ids[j]][k] for k in range(len(W[ids[i]]))))
    return {"means": means, "band": band, "entropy": ent,
            "domFree": 1000 * (len(ids) - len(dominant)) // len(ids),
            "dominant": dominant, "dominated": dominated, "viableCount": viable,
            "mutualPairs": mutual, "balanceScore": (1000 - band + ent) // 2}


def draw(m, ids, path):
    root = ashlib.svg_begin(640, 320)
    ashlib.svg_text(root, 22, 28, "《灰烬牌库》平衡的度量:分量与复合分", 15)
    ashlib.svg_text(root, 22, 48, "全部为代理指标:只做相对比较,禁绝对化", 11, "#94a3b8")
    mx = max(m["means"].values()) or 1
    for i, k in enumerate(ids):
        y = 66 + i * 22
        ashlib.svg_text(root, 22, y + 12, k, 12)
        ashlib.svg_bar(root, 110, y, 300, 14, m["means"][k], mx, fill="#38bdf8", label=f"{m['means'][k]}‰")
    comps = [("band", m["band"]), ("entropy", m["entropy"]), ("domFree", m["domFree"]),
             ("viableCount", m["viableCount"] * 1000 // len(ids)), ("balanceScore", m["balanceScore"])]
    ashlib.svg_text(root, 22, 216, "分量(千分比)", 13)
    for i, (k, v) in enumerate(comps):
        ashlib.svg_text(root, 22, 241 + i * 16, k, 11)
        ashlib.svg_text(root, 170, 241 + i * 16, f"{v}‰", 11, "#cbd5e1")
    ashlib.svg_text(root, 380, 250, f"balanceScore={m['balanceScore']}‰", 16, "#22c55e")
    return ashlib.svg_save(root, path)


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    ids, seeds = [p["id"] for p in cfg["policies"]], cfg["seeds"]
    tol = cfg["tolerances"]["viableTol"]
    W = matrix(spec, cfg, seeds)
    m = metrics(W, ids, tol)
    sweep = [{**{"seeds": c}, **{k: metrics(matrix(spec, cfg, seeds[:c]), ids, tol)[k]
                                 for k in ("band", "entropy", "viableCount", "balanceScore")}}
             for c in cfg["seedCounts"]]
    size = draw(m, ids, os.path.join(OUT, "play05_metric.svg"))
    report = {"id": "gd_play_05_metric", "seeds": len(seeds), "policies": ids, "matrix": W,
              "metrics": m, "seedSweep": sweep, "tolerances": cfg["tolerances"],
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "play05_metric.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print(f"{'策略':<9}{'行均值':>7}  遭遇胜率")
    for k in ids:
        print(f"{k:<9}{m['means'][k]:>7}  " + " ".join(f"{v:4d}" for v in W[k]))
    print(f"band={m['band']}‰  entropy={m['entropy']}‰  domFree={m['domFree']}‰  "
          f"viable={m['viableCount']}/{len(ids)}  mutual={m['mutualPairs']}")
    print(f"支配者={m['dominant']}  被支配者={m['dominated']}")
    print(f"balanceScore=({1000 - m['band']}+{m['entropy']})//2={m['balanceScore']}‰")
    for r in sweep:
        print(f"  seeds={r['seeds']:>3} band={r['band']:>4} entropy={r['entropy']:>4} "
              f"viable={r['viableCount']} score={r['balanceScore']}")
    print(f"svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play05_metric.svg out/play05_metric.json")
    ET.parse(os.path.join(OUT, "play05_metric.svg"))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

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

策略           行均值  遭遇胜率
greedy       640  1000  687 1000 1000  375  375  687    0
tank         796   875  125 1000 1000 1000 1000  750  625
thrifty      226   500    0    0  687    0    0  625    0
mixed         93   125    0    0  625    0    0    0    0
first        538   875    0  562 1000  562  562  500  250
last         515   750  250  562  937  312  312  812  187
band=703‰  entropy=914‰  domFree=166‰  viable=2/6  mutual=6
支配者=['first', 'greedy', 'last', 'tank', 'thrifty']  被支配者=['first', 'mixed', 'thrifty']
balanceScore=(297+914)//2=605‰
  seeds=  4 band= 688 entropy= 907 viable=3 score=609
  seeds=  8 band= 703 entropy= 915 viable=3 score=606
  seeds= 12 band= 708 entropy= 910 viable=3 score=601
  seeds= 16 band= 703 entropy= 914 viable=2 score=605
svg=4420 bytes  elapsed=892 ms
wrote out/play05_metric.svg out/play05_metric.json

判读:一个复合分,五个分量

分量实跑值读法
band703‰最弱 93‰ 到最强 796‰,差距大
entropy914‰六条打法的胜率分布接近均匀(满分 1000)
domFree166‰只有 1/6 的策略没被支配
viableCount2/6容差 200‰ 下只有 tank、greedy 够格
mutualPairs6六对策略互有胜负
balanceScore605‰(297 + 914) // 2

注意 band 与 entropy 在这里是同向的(带宽越小、熵越大越平衡),所以复合分把两者相加是合理的。domFree 却给出了相反的信号:支配者是 first、greedy、last、tank、thrifty,被支配者里同时出现了 first 和 thrifty——也就是说支配关系不是全序,存在互相支配的对。一个策略可以既支配别人、又被别人支配。

B 层:容差与采样对度量的影响

复合分 605‰ 看起来是个确定的数,但它的两个分量分别依赖两个可调的旋钮:容差决定「可用策略数」,种子数决定采样。B 层把这两个依赖扫出来。

#!/usr/bin/env python3
# file: sim/gd_play_05_metric_robustness.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_05_metric_robustness.py
"""卷二 5.2 平衡度量的稳健性(B 层):**永远 exit 0**,只提示不使构建失败。

读 out/play05_metric.json(5.2 S 层产物):① 把「可用策略」容差从 50 扫到 400‰,看可用数
怎么变、在哪一格跨过半;② 摆出四种种子规模下的 balanceScore 极差;③ domFree 偏低时提醒
「无支配」这一分量实际不成立。每条含 severity=soft 与 hardFail=false。
产出 out/play05_metric_robustness.json。
"""
import json
import os
import sys

OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "out")


def main():
    with open(os.path.join(OUT, "play05_metric.json"), encoding="utf-8") as f:
        rep = json.load(f)
    m, ids = rep["metrics"], rep["policies"]
    n = len(ids)
    warn = []
    sweep = [{"tol": t, "viableCount": sum(1 for v in m["means"].values()
                                           if v > 0 and v >= max(m["means"].values()) - t)}
             for t in (50, 100, 150, 200, 250, 300, 400)]
    half = next((s for s in sweep if s["viableCount"] * 2 >= n), None)
    warn.append({"code": "TOLERANCE_SWEEP", "severity": "soft", "sweep": sweep,
                 "halfAt": half["tol"] if half else None,
                 "message": f"容差从 50 扫到 400‰,可用策略数在 {sweep[0]['viableCount']}→"
                            f"{sweep[-1]['viableCount']} 之间移动;容差 "
                            f"{half['tol'] if half else '—'}‰ 时刚好过半。「可用」是阈值定义的,不是自然事实"})
    scores = [r["balanceScore"] for r in rep["seedSweep"]]
    spread = max(scores) - min(scores)
    warn.append({"code": "SAMPLING_SPREAD", "severity": "soft", "scores": scores, "spread": spread,
                 "message": f"四种种子规模下 balanceScore 为 {scores},极差 {spread}‰:"
                            + ("超过 10‰,比较不同配置时应固定种子数" if spread > 10
                               else "很小,复合分对采样规模不敏感")})
    if m["domFree"] < 500:
        warn.append({"code": "DOMINANCE_PRESENT", "severity": "soft", "domFree": m["domFree"],
                     "dominant": m["dominant"],
                     "message": f"domFree={m['domFree']}‰:{len(m['dominant'])}/{n} 个策略能严格支配别人,"
                                f"「无支配」这一分量实际不成立——复合分被带宽与熵撑住了"})
    warn.append({"code": "PROXY_ONLY", "severity": "soft",
                 "message": "band / entropy / domFree / viableCount 全部来自仿真胜率,是代理指标:"
                            "只做配置之间的相对比较,禁写「平衡度提升 N 倍」"})
    report = {"id": "gd_play_05_metric_robustness", "tier": "B", "hardFail": False,
              "note": "B 档软告警:只提示容差与采样对平衡度量的影响,不使任何构建失败",
              "balanceScore": m["balanceScore"], "domFree": m["domFree"], "toleranceSweep": sweep,
              "samplingScores": scores, "warnings": warn}
    with open(os.path.join(OUT, "play05_metric_robustness.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    for w in warn:
        print(f"SOFT WARN {w['code']}: {w['message']}")
    print(f"warnings={len(warn)}  hardFail=false  →  exit 0")
    print("wrote out/play05_metric_robustness.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

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

SOFT WARN TOLERANCE_SWEEP: 容差从 50 扫到 400‰,可用策略数在 1→4 之间移动;容差 300‰ 时刚好过半。「可用」是阈值定义的,不是自然事实
SOFT WARN SAMPLING_SPREAD: 四种种子规模下 balanceScore 为 [609, 606, 601, 605],极差 8‰:很小,复合分对采样规模不敏感
SOFT WARN DOMINANCE_PRESENT: domFree=166‰:5/6 个策略能严格支配别人,「无支配」这一分量实际不成立——复合分被带宽与熵撑住了
SOFT WARN PROXY_ONLY: band / entropy / domFree / viableCount 全部来自仿真胜率,是代理指标:只做配置之间的相对比较,禁写「平衡度提升 N 倍」
warnings=4  hardFail=false  →  exit 0
wrote out/play05_metric_robustness.json

三条提示合起来说明:复合分 605‰ 这个数本身不脆弱(采样极差只有 8‰),但它的分量分别脆弱——「可用策略数」随容差从 1 挪到 4,「无支配」几乎不成立(5/6 的策略都在支配别人)。换句话说,复合分把内部分歧平均掉了;要做决策,得回去看分量。

自检表

检查项合格标准
分量可数每个分量都能从胜率矩阵直接数出来
全整数落盘无浮点,熵在脚本内取整
复合分同口径比较不同配置必须用同一公式与同一容差
代理指标显式分量与复合分声明为代理指标,禁绝对化

度量做完了,还剩一个问题:如果「平衡」和「多样性」不是一回事,那能不能只追平衡?下一节用一个旋钮把两者拆开。

阅读导航:上一节:5.1 平衡的定义与层次 · 下一节:5.3 平衡与多样性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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