5.3 平衡与多样性

平衡与多样性常被当成一回事,其实是两条轴。本节扫一个旋钮——重击卡的伤害从 6 到 24——同时算平衡度(1000−带宽)与多样性(胜率熵)。结果是一条反例:重击伤害压到 6 时带宽掉到 0,看起来「最平衡」,但熵也掉到 0,六个打法一起赢不了,复合分反而是九行里最低的 500‰。

上一节的复合分把带宽与熵各算一半。可这两个量真的同向吗?直觉上「越平衡越多样」,但这是错觉。站内经济平衡的概念与示意代码 谈的是产出与消耗的匹配,平衡就是收支相等;本节要指出,在策略层,「收支相等」式的平衡有一个退化陷阱:让所有打法都赢不了,带宽也是 0。

5.3 平衡与多样性

把平衡与多样性拆成两条轴,只需要一个旋钮。本节拨的是重击卡(heavy)的伤害:从 6 一路加到 24。每拨一次,重算六个打法在八组遭遇上的胜率,得到两个量:

轴定义说明
平衡度1000 − band带宽越小越平衡,满分 1000‰
多样性胜率熵(同 5.2 的口径)六条打法胜率越均匀越高,满分 1000‰
复合分(1000 − band + entropy) // 2两条轴各占一半

两个轴都是代理指标,只做相对比较。要看清的是一条反例:把重击伤害压到最低时,带宽会掉到 0——但熵也掉到 0。band=0 只说明六个打法胜率一样,不说明它们都在玩:一起赢、一起输,都会让带宽变 0。

{ "seeds": [401, "…", 416],
  "knob": { "card": "heavy", "field": "damage", "values": [6, 8, 10, 12, 14, 16, 18, 20, 24] },
  "tolerances": { "viableTol": 200 } }

S 层:扫一个旋钮,看两条轴

脚本对旋钮的每个取值深拷贝一份规格、改掉 heavy 的伤害,再跑同一套策略 × 遭遇,算出两条轴并落盘 CSV + SVG。全整数,无浮点,无 random。

#!/usr/bin/env python3
# file: sim/gd_play_05_diversity.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_05_diversity.py
"""卷二 5.3 平衡与多样性(S 层):扫一个旋钮,看「平衡」与「多样性」是不是一回事。

旋钮 = 重击卡(heavy)的伤害,取值 6…24。每取一个值跑同一套策略 × 遭遇,算两个轴
(均为千分比整数,禁浮点):平衡度 = 1000 − band;多样性 = 行均值归一化后的
Shannon 熵 ÷ log2(策略数) ×1000;再加 balanceScore = (1000 − band + entropy) // 2。
要看清的是一条反例:**把重击伤害压到最低时 band 会掉到 0——但熵也掉到 0**,
那不是「平衡」,是六个策略一起赢不了。产出 out/play05_diversity.json + .csv + .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_diversity.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
KEYS = ("enemyHp", "turnLimit", "enemyAttack")


def choose(rule, snap, cost, dmg, blk, ea):
    """确定性策略规则:读当前快照,返回下一步命令(pK 出第 K 张,e 结束回合)。"""
    hand, en, turn = snap["hand"], snap["energy"], snap["turn"]
    aff = [i for i, cid in enumerate(hand) if cost[cid] <= 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 snap["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 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 row_means(spec, cfg, seeds):
    """每个策略在全部遭遇上的胜率均值(千分比整数)。"""
    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"]}
    means = {}
    for pol in cfg["policies"]:
        total = 0
        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):
                    snap = ashlib.run_spec(s, seq)
                    if snap["over"]:
                        break
                    seq.append(choose(pol["rule"], snap, cost, dmg, blk, enc["enemyAttack"]))
                wins += 1 if ashlib.run_spec(s, seq)["won"] else 0
            total += 1000 * wins // len(seeds)
        means[pol["id"]] = total // len(cfg["encounters"])
    return means


def axes(means, tol):
    """两个轴 + 可用策略数 + 复合分。"""
    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(means))))
    best = max(means.values())
    viable = sum(1 for v in means.values() if v > 0 and v >= best - tol)
    return {"band": band, "balance": 1000 - band, "diversity": ent,
            "viableCount": viable, "balanceScore": (1000 - band + ent) // 2}


def draw(pts, path):
    """平衡度(x)– 多样性(y)平面,退化点画红。"""
    x0, y0, x1, y1 = 70, 60, 600, 320
    root = ashlib.svg_begin(640, 380)
    ashlib.svg_text(root, 22, 28, "《灰烬牌库》平衡–多样性平面(旋钮:重击伤害)", 14)
    ashlib.svg_text(root, 22, 48, "x=平衡度(1000−band)  y=多样性(胜率熵)  均为代理指标", 11, "#94a3b8")
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x1), "y2": str(y1),
                                 "stroke": "#475569"})
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x0), "y2": str(y1),
                                 "stroke": "#475569"})
    ashlib.svg_text(root, x0 - 8, y0 + 6, "1000", 10, "#94a3b8", "end")
    ashlib.svg_text(root, x0 - 8, y1, "0", 10, "#94a3b8", "end")
    ashlib.svg_text(root, x1, y1 + 20, "1000", 10, "#94a3b8", "middle")
    ashlib.svg_text(root, (x0 + x1) // 2, y1 + 38, "平衡度 →", 11, "#cbd5e1", "middle")
    ashlib.svg_text(root, 26, (y0 + y1) // 2, "多样性 →", 11, "#cbd5e1")
    ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y0 + 6), "x2": str(x1), "y2": str(y0 + 6),
                                 "stroke": "#ef4444", "stroke-dasharray": "4 4"})
    ashlib.svg_text(root, x1 - 4, y0 + 20, "退化线:band=0 但没人赢", 10, "#ef4444", "end")
    for p in pts:
        px = x0 + (x1 - x0) * p["balance"] // 1000
        py = y1 - (y1 - y0) * p["diversity"] // 1000
        ET.SubElement(root, "circle", {"cx": str(px), "cy": str(py), "r": "5",
                                       "fill": "#38bdf8" if p["diversity"] > 0 else "#ef4444"})
        ashlib.svg_text(root, px + 8, py + 4, f"重击{p['knob']}", 10, "#cbd5e1")
    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)
    knob, tol = cfg["knob"], cfg["tolerances"]["viableTol"]
    pts, csv = [], []
    for v in knob["values"]:
        s = copy.deepcopy(spec)
        for c in s["cards"]:
            if c["id"] == knob["card"]:
                c[knob["field"]] = v
        means = row_means(s, cfg, cfg["seeds"])
        ax = axes(means, tol)
        pts.append({"knob": v, "means": means, **ax})
        csv.append([v, ax["band"], ax["balance"], ax["diversity"], ax["viableCount"],
                    ax["balanceScore"]])
    n = ashlib.write_csv(os.path.join(OUT, "play05_diversity.csv"),
                         ["heavyDamage", "band", "balance", "diversity", "viableCount",
                          "balanceScore"], csv)
    size = draw(pts, os.path.join(OUT, "play05_diversity.svg"))
    degen = [p["knob"] for p in pts if p["diversity"] == 0]
    report = {"id": "gd_play_05_diversity", "knob": knob, "seeds": len(cfg["seeds"]),
              "points": pts, "degenerateAt": degen,
              "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    with open(os.path.join(OUT, "play05_diversity.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}{'band':>7}{'平衡度':>8}{'多样性':>8}{'可用':>6}{'复合分':>8}")
    for p in pts:
        print(f"{p['knob']:>8}{p['band']:>7}{p['balance']:>8}{p['diversity']:>8}"
              f"{p['viableCount']:>6}{p['balanceScore']:>8}")
    print(f"退化为零的点(band=0 且 熵=0):{degen}")
    print(f"csv={n} 行  svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play05_diversity.csv out/play05_diversity.svg out/play05_diversity.json")
    ET.parse(os.path.join(OUT, "play05_diversity.svg"))
    return 0


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

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

    重击伤害   band     平衡度     多样性    可用     复合分
       6      0    1000       0     0     500
       8     23     977     374     2     675
      10    101     899     763     4     831
      12    500     500     916     1     708
      14    703     297     914     2     605
      16    757     243     914     2     578
      18    703     297     961     4     629
      20    469     531     985     4     758
      24    297     703     994     4     848
退化为零的点(band=0 且 熵=0):[6]
csv=9 行  svg=3126 bytes  elapsed=742 ms
wrote out/play05_diversity.csv out/play05_diversity.svg out/play05_diversity.json

判读:最「平衡」的那一行是最差的

重击伤害平衡度多样性复合分读法
610000500退化:没人赢,band=0 是假平衡
8977374675开始有人赢,但打法单一
10899763831平衡度与多样性都在高位
12500916708多样性接近满分,平衡度掉一半
14297914605基准值:不平衡但多样
16243914578九行里最不平衡
18297961629多样性最高之一
20531985758两条轴都高
24703994848复合分最高

看第一行与最后一行。重击伤害 = 6 时平衡度 1000‰——满分,因为它把 band 压到 0。可多样性也是 0:六个打法全都赢不了,胜率矩阵全零,谁跟谁都一样,带宽自然是 0。这不是平衡,是退化;复合分把它判成 (1000 + 0) // 2 = 500‰,是九行里最低的。反过来,重击伤害 = 24 时平衡度只有 703‰,但多样性 994‰,复合分 848‰ 最高。「最平衡」的那一行,恰恰是整体最差的一行。

B 层:退化点的分辨率与稳定性

结论里有两处需要复核:退化点(重击伤害 = 6)是一个孤立的点还是一段区间,以及最优点(24)的带宽稳不稳。B 层把旋钮逐 1 加密、扫容差、换种子数,全部软告警。

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

读 out/play05_diversity.json(5.3 S 层产物),做两件事:① 旋钮加密——把重击伤害从 6 逐 1
加到 10,看「退化角」是一个点还是一段区间;② 采样扰动——退化点与复合分最高点在
4/8/16 个种子下是否还成立。复用 5.3 S 层的 row_means / axes,不重写一遍。
产出 out/play05_diversity_robustness.json。
"""
import copy, json, os, sys
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_diversity.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
from gd_play_05_diversity import row_means, axes  # noqa: E402


def axes_at(spec, cfg, seeds, value):
    """把旋钮拨到 value 再算两轴。"""
    s = copy.deepcopy(spec)
    for c in s["cards"]:
        if c["id"] == cfg["knob"]["card"]:
            c[cfg["knob"]["field"]] = value
    return axes(row_means(s, cfg, seeds), cfg["tolerances"]["viableTol"])


def main():
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    with open(os.path.join(OUT, "play05_diversity.json"), encoding="utf-8") as f:
        rep = json.load(f)
    warn = []
    fine = [axes_at(spec, cfg, cfg["seeds"], v) for v in (6, 7, 8, 9, 10)]
    degen = [6 + i for i, a in enumerate(fine) if a["band"] == 0 and a["diversity"] == 0]
    warn.append({"code": "KNOB_RESOLUTION", "severity": "soft", "fineGrid": fine,
                 "degenerateValues": degen,
                 "message": f"重击伤害逐 1 加密到 6…10:退化点出现在 {degen},"
                            f"是{'一个区间' if len(degen) > 1 else '一个孤立点'};"
                            f"粗扫步长 2 可能把退化区间边界切丢"})
    hi = max(rep["points"], key=lambda p: p["balanceScore"])["knob"]
    for label, value in (("退化点", min(p["knob"] for p in rep["points"])), ("复合分最高点", hi)):
        rows = [{"seeds": c, **{k: axes_at(spec, cfg, cfg["seeds"][:c], value)[k]
                                for k in ("band", "diversity")}} for c in (4, 8, 16)]
        warn.append({"code": "SAMPLING_STABILITY", "severity": "soft", "point": label,
                     "knob": value, "rows": rows,
                     "message": f"{label}(重击伤害={value})在 4/8/16 个种子下的 "
                                f"(band, 多样性) = {[(r['band'], r['diversity']) for r in rows]}"})
    warn.append({"code": "PROXY_ONLY", "severity": "soft",
                 "message": "平衡度与多样性都来自仿真胜率,是代理指标:只做配置之间的相对比较,"
                            "禁写「多样性提升 N 倍」"})
    report = {"id": "gd_play_05_diversity_robustness", "tier": "B", "hardFail": False,
              "note": "B 档软告警:只提示旋钮分辨率与采样对平衡–多样性结论的影响",
              "fineGrid": fine, "degenerateValues": degen, "warnings": warn}
    with open(os.path.join(OUT, "play05_diversity_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_diversity_robustness.json")
    return 0


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

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

SOFT WARN KNOB_RESOLUTION: 重击伤害逐 1 加密到 6…10:退化点出现在 [6],是一个孤立点;粗扫步长 2 可能把退化区间边界切丢
SOFT WARN SAMPLING_STABILITY: 退化点(重击伤害=6)在 4/8/16 个种子下的 (band, 多样性) = [(0, 0), (0, 0), (0, 0)]
SOFT WARN SAMPLING_STABILITY: 复合分最高点(重击伤害=24)在 4/8/16 个种子下的 (band, 多样性) = [(407, 991), (297, 995), (297, 994)]
SOFT WARN PROXY_ONLY: 平衡度与多样性都来自仿真胜率,是代理指标:只做配置之间的相对比较,禁写「多样性提升 N 倍」
warnings=4  hardFail=false  →  exit 0
wrote out/play05_diversity_robustness.json

加密到逐 1 后,退化点只有 6 这一个值,是孤立点而非区间——粗扫步长 2 恰好没漏掉它,但换一个旋钮就未必。采样扰动显示:退化点的 (band, 多样性) = (0, 0) 在 4/8/16 个种子下完全稳定(它不靠运气),而最优点 24 的带宽会在 407‰ 与 297‰ 之间晃——所以比较不同旋钮取值的带宽时,种子数必须固定。

自检表

检查项合格标准
两条轴分开平衡度与多样性各自可算,不混为一谈
退化可识别band=0 且熵=0 判为退化,不判为平衡
旋钮入档扫哪个旋钮、取哪些值写进 config
代理指标显式两条轴都是代理指标,只做相对比较

平衡与多样性是两条轴,把重击伤害压低能买到「平衡」,代价是把游戏买死。旋钮继续往另一边拨会发生什么?下一章把「参数扫过某个点、系统整体换一种行为」这件事单拎出来讲。

阅读导航:上一节:5.2 平衡的度量 · 下一节:6.1 相变:参数扫过临界点 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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